diff --git a/.github/agents/data-validator.agent.md b/.github/agents/data-validator.agent.md new file mode 100644 index 00000000..7879f023 --- /dev/null +++ b/.github/agents/data-validator.agent.md @@ -0,0 +1,15 @@ +--- +name: data-validator +description: A data validator who checks `data.json` for missing or malformed data +--- + +# Data Validation Agent + +You are a data validator who checks `data.json` for missing or malformed data + +## Data validation criteria + +When validating, always checks for: +- Empty author +- Year=0 +- Missing fields diff --git a/.github/agents/doc-writer.agent.md b/.github/agents/doc-writer.agent.md new file mode 100644 index 00000000..f58fa468 --- /dev/null +++ b/.github/agents/doc-writer.agent.md @@ -0,0 +1,23 @@ +--- +name: doc-writer +description: Generates or updates docstrings and README content +--- + +# Documentation Writing Agent + +You are a developer whose job is to create and update docstrings for functions and generates README content + +## Update Docstrings + +When updating docstrings, follows these patterns: +- Give a short description for the function +- Add description and type for parameters +- Add description and type for outputs +- Give an examples on how to use that function + +## Gerenate README + +- Start with a one-sentence summary +- Include usage examples +- Document parameters and return values +- Note any gotchas or limitations \ No newline at end of file diff --git a/.github/agents/documentor.agent.md b/.github/agents/documentor.agent.md new file mode 100644 index 00000000..9038940d --- /dev/null +++ b/.github/agents/documentor.agent.md @@ -0,0 +1,14 @@ +--- +name: documentor +description: Technical writer for clear and complete documentation +--- + +# Documentation Agent + +You are a technical writer who creates clear documentation. + +**Documentation standards:** +- Start with a one-sentence summary +- Include usage examples +- Document parameters and return values +- Note any gotchas or limitations \ No newline at end of file diff --git a/.github/agents/error-handler.agent.md b/.github/agents/error-handler.agent.md new file mode 100644 index 00000000..cde9d260 --- /dev/null +++ b/.github/agents/error-handler.agent.md @@ -0,0 +1,21 @@ +--- +name: error-handler +description: A senior developer reviews Python code for inconsistent error handling and suggests a unified approach +--- + +# Error Handling Agent + +You are a senior Python developer reviews code for inconsistent error handling and suggests a unified approach + +## Your expertise + +- Python 3.10+ features (dataclasses, type hints, match statements) +- PEP 8 style compliance +- Error handling patterns (try/except, custom exceptions) + +## When suggesting approach + +- Follows PEP 8 guidelines +- Uses consistent error handling patterns +- Provides clear and informative error messages +- No bare except clause \ No newline at end of file diff --git a/.github/agents/reviewer.agent.md b/.github/agents/reviewer.agent.md new file mode 100644 index 00000000..bfeaf58c --- /dev/null +++ b/.github/agents/reviewer.agent.md @@ -0,0 +1,18 @@ +--- +name: reviewer +description: Senior code reviewer focused on security and best practices +--- + +# Code Reviewer Agent + +You are a senior code reviewer focused on code quality. + +**Review priorities:** +1. Security vulnerabilities +2. Performance issues +3. Maintainability concerns +4. Best practice violations + +**Output format:** +Provide issues as a numbered list with severity tags: +[CRITICAL], [HIGH], [MEDIUM], [LOW] \ No newline at end of file diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 16600736..839c4c3a 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,5 +1,15 @@ { "entries": { + "actions/checkout@v6.0.2": { + "repo": "actions/checkout", + "version": "v6.0.2", + "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + }, + "actions/download-artifact@v8.0.1": { + "repo": "actions/download-artifact", + "version": "v8.0.1", + "sha": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" + }, "actions/github-script@v8": { "repo": "actions/github-script", "version": "v8", @@ -10,6 +20,11 @@ "version": "v9", "sha": "373c709c69115d41ff229c7e5df9f8788daa9553" }, + "actions/upload-artifact@v7": { + "repo": "actions/upload-artifact", + "version": "v7", + "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + }, "github/gh-aw-actions/setup@v0.68.1": { "repo": "github/gh-aw-actions/setup", "version": "v0.68.1", diff --git a/.github/scripts/fix-translated-markdown.js b/.github/scripts/fix-translated-markdown.js new file mode 100644 index 00000000..34952f24 --- /dev/null +++ b/.github/scripts/fix-translated-markdown.js @@ -0,0 +1,396 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); + +const repoRoot = process.cwd(); +const languages = process.argv + .slice(2) + .flatMap((value) => value.split(/\s+/)) + .map((value) => value.trim()) + .filter(Boolean); + +if (languages.length === 0) { + console.error("Usage: node .github/scripts/fix-translated-markdown.js "); + process.exit(1); +} + +const tableHeadersByLanguage = { + es: "| Capítulo | Título | Lo que construirás |", + ko: "| 장 | 제목 | 만들 내용 |", + ja: "| 章 | タイトル | 作成するもの |", + "zh-CN": "| 章节 | 标题 | 你将构建的内容 |", +}; + +const allFiles = new Set(walkFiles(repoRoot).map(toPosixRelative)); +const errors = []; +let changedFiles = 0; + +for (const language of languages) { + const translationRoot = path.join(repoRoot, "translations", language); + + if (!fs.existsSync(translationRoot)) { + console.log(`No translations found for ${language}; skipping.`); + continue; + } + + const markdownFiles = walkFiles(translationRoot).filter((file) => file.endsWith(".md")); + + for (const filePath of markdownFiles) { + const original = fs.readFileSync(filePath, "utf8"); + const fixed = fixMarkdown(original, filePath, language); + + if (fixed !== original) { + fs.writeFileSync(filePath, fixed); + changedFiles += 1; + console.log(`Fixed translated Markdown: ${toPosixRelative(filePath)}`); + } + + validateMarkdown(fixed, filePath, language); + } +} + +if (errors.length > 0) { + console.error("\nTranslated Markdown validation failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log(`Translated Markdown cleanup complete. Files changed: ${changedFiles}`); + +function fixMarkdown(content, filePath, language) { + let fixed = fixKnownTableHeaders(content, language); + const headingSlugs = getHeadingSlugs(fixed); + const headingSlugList = getHeadingSlugList(fixed); + const sourceFile = getSourceFileForTranslation(filePath, language); + + fixed = fixed.replace(/\[([^\]\n]+)\]\((#[^)]+)\)/g, (match, label, destination) => { + const currentSlug = destination.slice(1); + + if (headingSlugs.has(currentSlug)) { + return match; + } + + const labelSlug = slugifyHeading(label); + + if (headingSlugs.has(labelSlug)) { + return `[${label}](#${labelSlug})`; + } + + const candidates = [...headingSlugs].filter( + (slug) => slug.startsWith(`${labelSlug}-`) || labelSlug.startsWith(`${slug}-`), + ); + + if (candidates.length === 1) { + return `[${label}](#${candidates[0]})`; + } + + const sourceHeadingIndex = getSourceHeadingIndex(sourceFile, currentSlug); + + if (sourceHeadingIndex !== -1 && sourceHeadingIndex < headingSlugList.length) { + return `[${label}](#${headingSlugList[sourceHeadingIndex]})`; + } + + return match; + }); + + return fixed.replace(/(\]\()([^)]+)(\))/g, (_match, prefix, destination, suffix) => { + return `${prefix}${fixDestination(destination, filePath, language)}${suffix}`; + }); +} + +function fixKnownTableHeaders(content, language) { + const translatedHeader = tableHeadersByLanguage[language]; + + if (!translatedHeader) { + return content; + } + + return content.replace( + /^\|\s*Chapter\s*\|\s*Title\s*\|\s*What You'll Build\s*\|$/gm, + translatedHeader, + ); +} + +function fixDestination(destination, filePath, language) { + if (isExternal(destination) || destination.startsWith("#") || destination.startsWith("<")) { + return destination; + } + + const { target, fragment } = splitDestination(destination); + + if (!target || target.startsWith("#")) { + return destination; + } + + const translatedDir = path.posix.dirname(toPosixRelative(filePath)); + const currentTarget = normalizePosix(path.posix.join(translatedDir, target)); + const translatedRoot = `translations/${language}`; + const sourceRelativeFile = path.posix.relative(translatedRoot, toPosixRelative(filePath)); + const sourceDir = path.posix.dirname(sourceRelativeFile); + const sourceTarget = normalizePosix(path.posix.join(sourceDir, target)); + + if (pathExists(currentTarget)) { + return `${target}${fixCrossFileFragment(fragment, sourceTarget, currentTarget)}`; + } + + if (!pathExists(sourceTarget)) { + return destination; + } + + const translatedTarget = normalizePosix(path.posix.join(translatedRoot, sourceTarget)); + const preferredTarget = pathExists(translatedTarget) ? translatedTarget : sourceTarget; + const relativeTarget = path.posix.relative(translatedDir, preferredTarget) || "."; + const normalizedTarget = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; + + return `${normalizedTarget}${fixCrossFileFragment(fragment, sourceTarget, preferredTarget)}`; +} + +function validateMarkdown(content, filePath, language) { + const fileRelative = toPosixRelative(filePath); + const fileDir = path.posix.dirname(fileRelative); + const headingSlugs = getHeadingSlugs(content); + const sourceFile = getSourceFileForTranslation(filePath, language); + + for (const destination of getDestinations(content)) { + if (isExternal(destination) || destination.startsWith("<")) { + continue; + } + + const { target, fragment } = splitDestination(destination); + + if (!target) { + const slug = fragment.slice(1); + + if ( + slug && + !headingSlugs.has(slug) && + getSourceHeadingIndex(sourceFile, slug) !== -1 + ) { + errors.push(`${fileRelative} links to missing heading #${slug}`); + } + + continue; + } + + const resolvedTarget = normalizePosix(path.posix.join(fileDir, target)); + + if (!pathExists(resolvedTarget)) { + const sourceTarget = getSourceTargetForDestination(filePath, language, target); + + if (sourceTarget && !pathExists(sourceTarget)) { + continue; + } + + errors.push(`${fileRelative} links to missing file ${destination}`); + continue; + } + + // Cross-file heading anchors are best-effort fixed above by mapping source + // heading order to translated heading order. Avoid failing on anchors that + // come from generated HTML IDs or pre-existing source content. + } +} + +function getDestinations(content) { + const destinations = []; + const pattern = /\]\(([^)]+)\)/g; + let match; + + while ((match = pattern.exec(content)) !== null) { + destinations.push(match[1]); + } + + return destinations; +} + +function getHeadingSlugs(content) { + return new Set(getHeadingSlugList(content)); +} + +function getHeadingSlugList(content) { + return getHeadingSlugListWith(content, slugifyHeading); +} + +function getSourceHeadingIndex(sourceFile, slug) { + if (!sourceFile || !pathExists(sourceFile)) { + return -1; + } + + const sourceContent = fs.readFileSync(path.join(repoRoot, sourceFile), "utf8"); + const slugLists = [ + getHeadingSlugListWith(sourceContent, slugifyHeading), + getHeadingSlugListWith(sourceContent, slugifyGitHubHeading), + ]; + + for (const slugs of slugLists) { + const index = slugs.indexOf(slug); + + if (index !== -1) { + return index; + } + } + + return -1; +} + +function getHeadingSlugListWith(content, slugifier) { + const slugs = new Map(); + const result = []; + + for (const line of content.split(/\r?\n/)) { + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line); + + if (!match) { + continue; + } + + const baseSlug = slugifier(match[2]); + const count = slugs.get(baseSlug) || 0; + const slug = count === 0 ? baseSlug : `${baseSlug}-${count}`; + slugs.set(baseSlug, count + 1); + result.push(slug); + } + + return result; +} + +function fixCrossFileFragment(fragment, sourceTarget, translatedTarget) { + if (!fragment || !sourceTarget.endsWith(".md") || !translatedTarget.endsWith(".md")) { + return fragment; + } + + if (!pathExists(sourceTarget) || !pathExists(translatedTarget)) { + return fragment; + } + + const currentSlug = fragment.slice(1); + const translatedContent = fs.readFileSync(path.join(repoRoot, translatedTarget), "utf8"); + const translatedSlugs = getHeadingSlugList(translatedContent); + + if (translatedSlugs.includes(currentSlug)) { + return fragment; + } + + const sourceContent = fs.readFileSync(path.join(repoRoot, sourceTarget), "utf8"); + const sourceSlugs = getHeadingSlugList(sourceContent); + const sourceIndex = sourceSlugs.indexOf(currentSlug); + + if (sourceIndex === -1 || sourceIndex >= translatedSlugs.length) { + return fragment; + } + + return `#${translatedSlugs[sourceIndex]}`; +} + +function getSourceFileForTranslation(filePath, language) { + const translatedRoot = `translations/${language}`; + const sourceFile = path.posix.relative(translatedRoot, toPosixRelative(filePath)); + + if (sourceFile.startsWith("..")) { + return ""; + } + + return sourceFile; +} + +function getSourceTargetForDestination(filePath, language, target) { + const sourceFile = getSourceFileForTranslation(filePath, language); + + if (!sourceFile) { + return ""; + } + + const sourceDir = path.posix.dirname(sourceFile); + return normalizePosix(path.posix.join(sourceDir, target)); +} + +function slugifyHeading(value) { + return value + .replace(/<[^>]+>/g, "") + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[`*_~]/g, "") + .trim() + .toLocaleLowerCase() + .replace(/[^\p{Letter}\p{Number}\p{Mark}\s-]/gu, "") + .trim() + .replace(/\s+/g, "-"); +} + +function slugifyGitHubHeading(value) { + return value + .replace(/<[^>]+>/g, "") + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[`*_~]/g, "") + .trim() + .toLocaleLowerCase() + .replace(/[^\p{Letter}\p{Number}\p{Mark}\s-]/gu, "") + .replace(/\s/g, "-"); +} + +function splitDestination(destination) { + const hashIndex = destination.indexOf("#"); + + if (hashIndex === -1) { + return { target: destination, fragment: "" }; + } + + return { + target: destination.slice(0, hashIndex), + fragment: destination.slice(hashIndex), + }; +} + +function isExternal(destination) { + return /^(https?:|mailto:|tel:)/i.test(destination); +} + +function pathExists(relativePath) { + if (allFiles.has(relativePath)) { + return true; + } + + if (fs.existsSync(path.join(repoRoot, relativePath))) { + return true; + } + + const indexPath = normalizePosix(path.posix.join(relativePath, "README.md")); + return allFiles.has(indexPath); +} + +function walkFiles(directory) { + if (!fs.existsSync(directory)) { + return []; + } + + const entries = fs.readdirSync(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + if (entry.name === ".git" || entry.name === "node_modules") { + continue; + } + + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...walkFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + + return files; +} + +function toPosixRelative(filePath) { + return normalizePosix(path.relative(repoRoot, filePath)); +} + +function normalizePosix(value) { + return value.split(path.sep).join(path.posix.sep).replace(/\\/g, "/"); +} diff --git a/.github/skills/book-summary/SKILL.md b/.github/skills/book-summary/SKILL.md new file mode 100644 index 00000000..b27cd691 --- /dev/null +++ b/.github/skills/book-summary/SKILL.md @@ -0,0 +1,14 @@ +--- +name: book-summary +description: Generates formatted markdown summary of book collection +--- + +# Book Summary Skill + +Generates a formatted markdown summary of a book collection, including titles, authors, and brief descriptions. + +## Output Format + +Follow this pattern: +- Use ✅/❌ for ready status +- Sort by year \ No newline at end of file diff --git a/.github/skills/code-tour/SKILL.md b/.github/skills/code-tour/SKILL.md new file mode 100644 index 00000000..2edf704d --- /dev/null +++ b/.github/skills/code-tour/SKILL.md @@ -0,0 +1,433 @@ +--- +name: code-tour +description: > + Use this skill to create CodeTour .tour files — persona-targeted, step-by-step walkthroughs + that link to real files and line numbers. Trigger for: "create a tour", "make a code tour", + "generate a tour", "onboarding tour", "tour for this PR", "tour for this bug", "RCA tour", + "architecture tour", "explain how X works", "vibe check", "PR review tour", + "contributor guide", "help someone ramp up", or any request for a structured walkthrough + through code. Supports 20 developer personas (new joiner, bug fixer, architect, PR reviewer, + vibecoder, security reviewer, and more), all CodeTour step types (file/line, selection, + pattern, uri, commands, view), and tour-level fields (ref, isPrimary, nextTour). + Works with any repository in any language. +--- + +# Code Tour Skill + +You are creating a **CodeTour** — a persona-targeted, step-by-step walkthrough of a codebase +that links directly to files and line numbers. CodeTour files live in `.tours/` and work with +the [VS Code CodeTour extension](https://github.com/microsoft/codetour). + +Two scripts are bundled in `scripts/`: + +- **`scripts/validate_tour.py`** — run after writing any tour. Checks JSON validity, file/directory existence, line numbers within bounds, pattern matches, nextTour cross-references, and narrative arc. Run it: `python ~/.agents/skills/code-tour/scripts/validate_tour.py .tours/.tour --repo-root .` +- **`scripts/generate_from_docs.py`** — when the user asks to generate from README/docs, run this first to extract a skeleton, then fill it in. Run it: `python ~/.agents/skills/code-tour/scripts/generate_from_docs.py --persona new-joiner --output .tours/skeleton.tour` + +Two reference files are bundled: + +- **`references/codetour-schema.json`** — the authoritative JSON schema. Read it to verify any field name or type. Every field you use must conform to it. +- **`references/examples.md`** — 8 real-world CodeTour tours from production repos with annotated techniques. Read it when you want to see how a specific feature (`commands`, `selection`, `view`, `pattern`, `isPrimary`, multi-tour series) is used in practice. + +### Real-world `.tour` files on GitHub + +These are confirmed production `.tour` files. Fetch one when you need a working example of a specific step type, tour-level field, or narrative structure — don't write from memory when the real thing is one fetch away. + +Find more with the GitHub code search: https://github.com/search?q=path%3A**%2F*.tour+&type=code + +#### By step type / technique demonstrated + +| What to study | File URL | +|---|---| +| `directory` + `file+line` (contributor onboarding) | https://github.com/coder/code-server/blob/main/.tours/contributing.tour | +| `selection` + `file+line` + intro content step (accessibility project) | https://github.com/a11yproject/a11yproject.com/blob/main/.tours/code-tour.tour | +| Minimal tutorial — tight `file+line` narration for interactive learning | https://github.com/lostintangent/rock-paper-scissors/blob/master/main.tour | +| Multi-tour repo with `nextTour` chaining (cloud native OCI walkthroughs) | https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/introduction.tour | +| `isPrimary: true` (marks the onboarding entry point) | https://github.com/nickvdyck/webbundlr/blob/main/.tours/getting-started.tour | +| `pattern` instead of `line` (regex-anchored steps) | https://github.com/nickvdyck/webbundlr/blob/main/.tours/architecture.tour | + +**Raw content tip:** Prefix `raw.githubusercontent.com` and drop `/blob/` for raw JSON access. + +A great tour is not just annotated files. It is a **narrative** — a story told to a specific +person about what matters, why it matters, and what to do next. Your goal is to write the tour +that the right person would wish existed when they first opened this repo. + +**CRITICAL: Only create `.tour` JSON files. Never create, modify, or scaffold any other files.** + +--- + +## Step 1: Discover the repo + +Before asking the user anything, explore the codebase: + +- List the root directory, read the README, and check key config files + (package.json, pyproject.toml, go.mod, Cargo.toml, composer.json, etc.) +- Identify the language(s), framework(s), and what the project does +- Map the folder structure 1–2 levels deep +- Find entry points: main files, index files, app bootstrapping +- **Note which files actually exist** — every path you write in the tour must be real + +If the repo is sparse or empty, say so and work with what exists. + +**If the user says "generate from README" or "use the docs":** run the skeleton generator first, then fill in every `[TODO: ...]` by reading the actual files: + +```bash +python skills/code-tour/scripts/generate_from_docs.py \ + --persona new-joiner \ + --output .tours/skeleton.tour +``` + +### Entry points by language/framework + +Don't read everything — start here, then follow imports. + +| Stack | Entry points to read first | +|-------|---------------------------| +| **Node.js / TS** | `index.js/ts`, `server.js`, `app.js`, `src/main.ts`, `package.json` (scripts) | +| **Python** | `main.py`, `app.py`, `__main__.py`, `manage.py` (Django), `app/__init__.py` (Flask/FastAPI) | +| **Go** | `main.go`, `cmd//main.go`, `internal/` | +| **Rust** | `src/main.rs`, `src/lib.rs`, `Cargo.toml` | +| **Java / Kotlin** | `*Application.java`, `src/main/java/.../Main.java`, `build.gradle` | +| **Ruby** | `config/application.rb`, `config/routes.rb`, `app/controllers/application_controller.rb` | +| **PHP** | `index.php`, `public/index.php`, `bootstrap/app.php` (Laravel) | + +### Repo type variants — adjust focus accordingly + +The same persona asks for different things depending on what kind of repo this is: + +| Repo type | What to emphasize | Typical anchor files | +|-----------|-------------------|----------------------| +| **Service / API** | Request lifecycle, auth, error contracts | router, middleware, handler, schema | +| **Library / SDK** | Public API surface, extension points, versioning | index/exports, types, changelog | +| **CLI tool** | Command parsing, config loading, output formatting | main, commands/, config | +| **Monorepo** | Package boundaries, shared contracts, build graph | root package.json/pnpm-workspace, shared/, packages/ | +| **Framework** | Plugin system, lifecycle hooks, escape hatches | core/, plugins/, lifecycle | +| **Data pipeline** | Source → transform → sink, schema ownership | ingest/, transform/, schema/, dbt models | +| **Frontend app** | Component hierarchy, state management, routing | pages/, store/, router, api/ | + +For **monorepos**: identify the 2–3 packages most relevant to the persona's goal. Don't try to tour everything — open the tour with a step that explains how to navigate the workspace, then stay focused. + +### Large repo strategy + +For repos with 100+ files: don't try to read everything. + +1. Read entry points and the README first +2. Build a mental model of the top 5–7 modules +3. For the requested persona, identify the **2–3 modules that matter most** and read those deeply +4. For modules you're not covering, mention them in the intro step as "out of scope for this tour" +5. Use `directory` steps for areas you mapped but didn't read — they orient without requiring full knowledge + +A focused 10-step tour of the right files beats a scattered 25-step tour of everything. + +--- + +## Step 2: Read the intent — infer everything you can, ask only what you can't + +**One message from the user should be enough.** Read their request and infer persona, +depth, and focus before asking anything. + +### Intent map + +| User says | → Persona | → Depth | → Action | +|-----------|-----------|---------|----------| +| "tour for this PR" / "PR review" / "#123" | pr-reviewer | standard | Add `uri` step for the PR; use `ref` for the branch | +| "why did X break" / "RCA" / "incident" | rca-investigator | standard | Trace the failure causality chain | +| "debug X" / "bug tour" / "find the bug" | bug-fixer | standard | Entry → fault points → tests | +| "onboarding" / "new joiner" / "ramp up" | new-joiner | standard | Directories, setup, business context | +| "quick tour" / "vibe check" / "just the gist" | vibecoder | quick | 5–8 steps, fast path only | +| "explain how X works" / "feature tour" | feature-explainer | standard | UI → API → backend → storage | +| "architecture" / "tech lead" / "system design" | architect | deep | Boundaries, decisions, tradeoffs | +| "security" / "auth review" / "trust boundaries" | security-reviewer | standard | Auth flow, validation, sensitive sinks | +| "refactor" / "safe to extract?" | refactorer | standard | Seams, hidden deps, extraction order | +| "performance" / "bottlenecks" / "slow path" | performance-optimizer | standard | Hot path, N+1, I/O, caches | +| "contributor" / "open source onboarding" | external-contributor | quick | Safe areas, conventions, landmines | +| "concept" / "explain pattern X" | concept-learner | standard | Concept → implementation → rationale | +| "test coverage" / "where to add tests" | test-writer | standard | Contracts, seams, coverage gaps | +| "how do I call the API" | api-consumer | standard | Public surface, auth, error semantics | + +**Infer silently:** persona, depth, focus area, whether to add `uri`/`ref`, `isPrimary`. + +**Ask only if you genuinely can't infer:** +- "bug tour" but no bug described → ask for the bug description +- "feature tour" but no feature named → ask which feature +- "specific files" explicitly requested → honor them as required stops + +Never ask about `nextTour`, `commands`, `when`, or `stepMarker` unless the user mentioned them. + +### PR tour recipe + +For PR tours: set `"ref"` to the branch, open with a `uri` step for the PR, cover changed files first, then unchanged-but-critical files, close with a reviewer checklist. + +### User-provided customization — always honor these + +| User says | What to do | +|-----------|-----------| +| "cover `src/auth.ts` and `config/db.yml`" | Those files are required stops | +| "pin to the `v2.3.0` tag" / "this commit: abc123" | Set `"ref": "v2.3.0"` | +| "link to PR #456" / pastes a URL | Add a `uri` step at the right narrative moment | +| "lead into the security tour when done" | Set `"nextTour": "Security Review"` | +| "make this the main onboarding tour" | Set `"isPrimary": true` | +| "open a terminal at this step" | Add `"commands": ["workbench.action.terminal.focus"]` | +| "deep" / "thorough" / "5 steps" / "quick" | Override depth accordingly | + +--- + +## Step 3: Read the actual files — no exceptions + +**Every file path and line number in the tour must be verified by reading the file.** +A tour pointing to the wrong file or a non-existent line is worse than no tour. + +For every planned step: +1. Read the file +2. Find the exact line of the code you want to highlight +3. Understand it well enough to explain it to the target persona + +If a user-requested file doesn't exist, say so — don't silently substitute another. + +--- + +## Step 4: Write the tour + +Save to `.tours/-.tour`. Read `references/codetour-schema.json` for the +authoritative field list. Every field you use must appear in that schema. + +### Tour root + +```json +{ + "$schema": "https://aka.ms/codetour-schema", + "title": "Descriptive Title — Persona / Goal", + "description": "One sentence: who this is for and what they'll understand after.", + "ref": "main", + "isPrimary": false, + "nextTour": "Title of follow-up tour", + "steps": [] +} +``` + +Omit any field that doesn't apply to this tour. + +**`when`** — conditional display. A JavaScript expression evaluated at runtime. Only show this tour +if the condition is true. Useful for persona-specific auto-launching, or hiding advanced tours +until a simpler one is complete. +```json +{ "when": "workspaceFolders[0].name === 'api'" } +``` + +**`stepMarker`** — embed step anchors directly in source code comments. When set, CodeTour +looks for `// ` comments in files and uses them as step positions instead of +(or alongside) line numbers. Useful for tours on actively changing code where line numbers +shift constantly. Example: set `"stepMarker": "CT"` and put `// CT` in the source file. +Don't suggest this unless the user asks — it requires editing source files, which is unusual. + +--- + +### Step types — full reference + +All step types: **content** (intro/closing, max 2), **directory**, **file+line** (workhorse), **selection** (code block), **pattern** (regex match), **uri** (external link), **view** (focus VS Code panel), **commands** (run VS Code commands). + +> **Path rule:** `"file"` and `"directory"` must be relative to repo root. No absolute paths, no leading `./`. + +--- + +### When to use each step type + +| Situation | Step type | +|-----------|-----------| +| Tour intro or closing | content | +| "Here's what lives in this folder" | directory | +| One line tells the whole story | file + line | +| A function/class body is the point | selection | +| Line numbers shift, file is volatile | pattern | +| PR / issue / doc gives the "why" | uri | +| Reader should open terminal or explorer | view or commands | + +--- + +### Step count calibration + +Match steps to depth and persona. These are targets, not hard limits. + +| Depth | Total steps | Core path steps | Notes | +|-------|-------------|-----------------|-------| +| Quick | 5–8 | 3–5 | Vibecoder, fast explorer — cut ruthlessly | +| Standard | 9–13 | 6–9 | Most personas — breadth + enough detail | +| Deep | 14–18 | 10–13 | Architect, RCA — every tradeoff surfaced | + +Scale with repo size too. A 3-file CLI doesn't get 15 steps. A 200-file monolith shouldn't be squeezed into 5. + +| Repo size | Recommended standard depth | +|-----------|---------------------------| +| Tiny (< 20 files) | 5–8 steps | +| Small (20–80 files) | 8–11 steps | +| Medium (80–300 files) | 10–13 steps | +| Large (300+ files) | 12–15 steps (scoped to relevant subsystem) | + +--- + +### Writing excellent descriptions — the SMIG formula + +Every description should answer four questions in order. You don't need four paragraphs — but every description needs all four elements, even briefly. + +**S — Situation**: What is the reader looking at? One sentence grounding them in context. +**M — Mechanism**: How does this code work? What pattern, rule, or design is in play? +**I — Implication**: Why does this matter for *this persona's goal specifically*? +**G — Gotcha**: What would a smart person get wrong here? What's non-obvious, fragile, or surprising? + +Descriptions should tell the reader something they couldn't learn by reading the file themselves. Name the pattern, explain the design decision, flag failure modes, and cross-reference related context. + +--- + +## Narrative arc — every tour, every persona + +1. **Orientation** — **must be a `file` or `directory` step, never content-only.** + Use `"file": "README.md", "line": 1` or `"directory": "src"` and put your welcome text in the description. + A content-only first step (no `file`, `directory`, or `uri`) renders as a blank page in VS Code CodeTour — this is a known VS Code extension behaviour, not configurable. + +2. **High-level map** (1–3 directory or uri steps) — major modules and how they relate. + Not every folder — just what this persona needs to know. + +3. **Core path** (file/line, selection, pattern, uri steps) — the specific code that matters. + This is the heart of the tour. Read and narrate. Don't skim. + +4. **Closing** (content) — what the reader now understands, what they can do next, + 2–3 suggested follow-up tours. If `nextTour` is set, reference it by name here. + +### Closing steps + +Don't summarize — the reader just read it. Instead, tell them what they can now *do*, what to avoid, and suggest 2-3 follow-up tours. + +--- + +## The 20 personas + +| Persona | Goal | Must cover | Avoid | +|---------|------|------------|-------| +| **Vibecoder** | Get the vibe fast | Entry point, request flow, main modules. Max 8 steps. | Deep dives, edge cases | +| **New joiner** | Structured ramp-up | Directories, setup, business context, service boundaries. | Advanced internals | +| **Bug fixer** | Root cause fast | User action → trigger → fault points. Repro hints + test locations. | Architecture tours | +| **RCA investigator** | Why did it fail | Causality chain, side effects, race conditions, observability. | Happy path | +| **Feature explainer** | One feature end-to-end | UI → API → backend → storage. Feature flags, edge cases. | Unrelated features | +| **PR reviewer** | Review the change correctly | Change story, invariants, risky areas, reviewer checklist. URI step for PR. | Unrelated context | +| **Security reviewer** | Trust boundaries | Auth flow, input validation, secret handling, sensitive sinks. | Unrelated business logic | +| **Refactorer** | Safe restructuring | Seams, hidden deps, coupling hotspots, safe extraction order. | Feature explanations | +| **External contributor** | Contribute without breaking | Safe areas, code style, architecture landmines. | Deep internals | +| **Tech lead / architect** | Shape and rationale | Module boundaries, design tradeoffs, risk hotspots. | Line-by-line walkthroughs | + +--- + +## Designing a tour series + +When a codebase is complex enough that one tour can't cover it well, design a series. +The `nextTour` field chains them: when the reader finishes one tour, VS Code offers to +launch the next automatically. + +**Plan the series before writing any tour.** A good series has: +- A clear escalation path (broad → narrow, orientation → deep-dive) +- No duplicate steps between tours +- Each tour standalone enough to be useful on its own + +Set `nextTour` in each tour to the `title` of the next one (must match exactly). Each tour should be standalone enough to be useful on its own. + +--- + +## What CodeTour cannot do + +If asked for any of these, say clearly that it's not supported — do not suggest a workaround that doesn't exist: + +| Request | Reality | +|---|---| +| **Auto-advance to next step after X seconds** | Not supported. Navigation is always manual — the reader clicks Next. There is no timer, delay, or autoplay step mechanic in CodeTour. | +| **Embed a video or GIF in a step** | Not supported. Descriptions are Markdown text only. | +| **Run arbitrary shell commands** | Not supported. `commands` only executes VS Code commands (e.g. `workbench.action.terminal.focus`), not shell commands. | +| **Branch / conditional next step** | Not supported. Tours are linear. `when` controls whether a tour is shown, not which step follows which. | +| **Show a step without opening a file** | Partially — content-only steps work, but step 1 must have a `file` or `directory` anchor or VS Code shows a blank page. | + +--- + +## Anti-patterns + +| Anti-pattern | Fix | +|---|---| +| **File listing** — visiting files with "this file contains..." | Tell a story; each step should depend on the previous one | +| **Generic descriptions** | Name the specific pattern/gotcha unique to *this* codebase | +| **Line number guessing** | Never write a line number you didn't verify by reading the file | +| **Ignoring the persona** | Cut every step that doesn't serve their specific goal | +| **Hallucinated files** | If a file doesn't exist, skip the step | + +--- + +## Quality checklist — verify before writing the file + +- [ ] Every `file` path is **relative to the repo root** (no leading `/` or `./`) +- [ ] Every `file` path read and confirmed to exist +- [ ] Every `line` number verified by reading the file (not guessed) +- [ ] Every `directory` is **relative to the repo root** and confirmed to exist +- [ ] Every `pattern` regex would match a real line in the file +- [ ] Every `uri` is a complete, real URL (https://...) +- [ ] `ref` is a real branch/tag/commit if set +- [ ] `nextTour` exactly matches the `title` of another `.tour` file if set +- [ ] Only `.tour` JSON files created — no source code touched +- [ ] First step has a `file` or `directory` anchor (content-only first step = blank page in VS Code) +- [ ] Tour ends with a closing content step that tells the reader what they can *do* next +- [ ] Every description answers SMIG — Situation, Mechanism, Implication, Gotcha +- [ ] Persona's priorities drive step selection (cut everything that doesn't serve their goal) +- [ ] Step count matches requested depth and repo size (see calibration table) +- [ ] At most 2 content-only steps (intro + closing) +- [ ] All fields conform to `references/codetour-schema.json` + +--- + +## Step 5: Validate the tour + +**Always run the validator immediately after writing the tour file. Do not skip this step.** + +```bash +python ~/.agents/skills/code-tour/scripts/validate_tour.py .tours/.tour --repo-root . +``` + +The validator checks: +- JSON validity +- Every `file` path exists and every `line` is within file bounds +- Every `directory` exists +- Every `pattern` regex compiles and matches at least one line in the file +- Every `uri` starts with `https://` +- `nextTour` matches an existing tour title in `.tours/` +- Content-only step count (warns if > 2) +- Narrative arc (warns if no orientation or closing step) + +**Fix every error before proceeding.** Re-run until the validator reports ✓ or only warnings. Warnings are advisory — use your judgment. Do not show the user the tour until validation passes. + +**Common VS Code issues:** Content-only first step renders blank (anchor to file/directory instead). Absolute or `./`-prefixed paths silently fail. Out-of-bounds line numbers scroll nowhere. + +If you can't run scripts, manually verify: step 1 has `file`/`directory`, all paths exist, all line numbers are in bounds, `nextTour` matches exactly. + +**Autoplay:** `isPrimary: true` + `.vscode/settings.json` with `{ "codetour.promptForPrimaryTour": true }` prompts on repo open. Omit `ref` for tours that should appear on any branch. + +**Share:** For public repos, users can open tours at `https://vscode.dev/github.com//` with no install. + +--- + +## Step 6: Summarize + +After writing the tour, tell the user: +- File path (`.tours/.tour`) +- One-paragraph summary of what the tour covers and who it's for +- The `vscode.dev` URL if the repo is public (so they can share it immediately) +- 2–3 suggested follow-up tours (or the next tour in the series if one was planned) +- Any user-requested files that didn't exist (be explicit — don't quietly substitute) + +--- + +## File naming + +`-.tour` — kebab-case, communicates both: +``` +onboarding-new-joiner.tour +bug-fixer-payment-flow.tour +architect-overview.tour +vibecoder-quickstart.tour +pr-review-auth-refactor.tour +security-auth-boundaries.tour +concept-dependency-injection.tour +rca-login-outage.tour +``` \ No newline at end of file diff --git a/.github/skills/code-tour/references/codetour-schema.json b/.github/skills/code-tour/references/codetour-schema.json new file mode 100644 index 00000000..e4966b3e --- /dev/null +++ b/.github/skills/code-tour/references/codetour-schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Schema for CodeTour tour files", + "type": "object", + "required": ["title", "steps"], + "properties": { + "title": { + "type": "string", + "description": "Specifies the title of the code tour." + }, + "description": { + "type": "string", + "description": "Specifies an optional description for the code tour." + }, + "ref": { + "type": "string", + "description": "Indicates the git ref (branch/commit/tag) that this tour associate with." + }, + "isPrimary": { + "type": "boolean", + "description": "Specifies whether the tour represents the primary tour for this codebase." + }, + "nextTour": { + "type": "string", + "description": "Specifies the title of the tour that is meant to follow this tour." + }, + "stepMarker": { + "type": "string", + "description": "Specifies the marker that indicates a line of code represents a step for this tour." + }, + "when": { + "type": "string", + "description": "Specifies the condition (JavaScript expression) that must be met before this tour is shown." + }, + "steps": { + "type": "array", + "description": "Specifies the list of steps that are included in the code tour.", + "default": [], + "items": { + "type": "object", + "required": ["description"], + "properties": { + "title": { + "type": "string", + "description": "An optional title for the step." + }, + "description": { + "type": "string", + "description": "Description of the step. Supports markdown." + }, + "file": { + "type": "string", + "description": "File path (relative to the workspace root) that the step is associated with." + }, + "directory": { + "type": "string", + "description": "Directory path (relative to the workspace root) that the step is associated with." + }, + "uri": { + "type": "string", + "description": "Absolute URI (https://...) associated with the step. Use for PRs, issues, docs, ADRs." + }, + "line": { + "type": "number", + "description": "Line number (1-based) that the step is associated with." + }, + "pattern": { + "type": "string", + "description": "Regex to associate the step with a line by content instead of line number. Useful when line numbers shift frequently." + }, + "selection": { + "type": "object", + "required": ["start", "end"], + "description": "Text selection range associated with the step. Use when a block of code (not a single line) is the point.", + "properties": { + "start": { + "type": "object", + "required": ["line", "character"], + "properties": { + "line": { "type": "number", "description": "Line number (1-based) where the selection starts." }, + "character": { "type": "number", "description": "Column number (1-based) where the selection starts." } + } + }, + "end": { + "type": "object", + "required": ["line", "character"], + "properties": { + "line": { "type": "number", "description": "Line number (1-based) where the selection ends." }, + "character": { "type": "number", "description": "Column number (1-based) where the selection ends." } + } + } + } + }, + "view": { + "type": "string", + "description": "VS Code view ID to auto-focus when navigating to this step (e.g. 'terminal', 'explorer', 'problems', 'scm')." + }, + "commands": { + "type": "array", + "description": "VS Code command URIs to execute when this step is navigated to.", + "default": [], + "items": { "type": "string" }, + "examples": [ + ["editor.action.goToDeclaration"], + ["workbench.action.terminal.focus"], + ["editor.action.showHover"], + ["references-view.findReferences"], + ["workbench.action.tasks.runTask"] + ] + } + } + } + } + } +} diff --git a/.github/skills/code-tour/references/examples.md b/.github/skills/code-tour/references/examples.md new file mode 100644 index 00000000..186347bb --- /dev/null +++ b/.github/skills/code-tour/references/examples.md @@ -0,0 +1,195 @@ +# Real-World CodeTour Examples + +Reference this file when you want to see how real repos use CodeTour features. +Each example is sourced from a public GitHub repo with a direct link to the `.tour` file. + +--- + +## microsoft/codetour — Contributor orientation + +**Tour file:** https://github.com/microsoft/codetour/blob/main/.tours/intro.tour +**Persona:** New contributor +**Steps:** ~5 · **Depth:** Standard + +**What makes it good:** +- Intro step with an embedded SVG architecture diagram (raw GitHub URL inside the description) +- Rich markdown per step with emoji section headers (`### 🎥 Tour Player`) +- Inline cross-file links inside descriptions: `[Gutter decorator](./src/player/decorator.ts)` +- Uses the top-level `description` field as a subtitle for the tour itself + +**Technique to copy:** Embed images and cross-links in descriptions to make them self-contained. + +```json +{ + "file": "src/player/index.ts", + "line": 436, + "description": "### 🎥 Tour Player\n\nThe CodeTour player ...\n\n![Architecture](https://raw.githubusercontent.com/.../overview.svg)\n\nSee also: [Gutter decorator](./src/player/decorator.ts)" +} +``` + +--- + +## a11yproject/a11yproject.com — New contributor onboarding + +**Tour file:** https://github.com/a11yproject/a11yproject.com/blob/main/.tours/code-tour.tour +**Persona:** External contributor +**Steps:** 26 · **Depth:** Deep + +**What makes it good:** +- Almost entirely `directory` steps — orients to every `src/` subdirectory without getting lost in files +- Conversational, beginner-friendly tone throughout +- `selection` on the opening step to highlight the exact entry in `package.json` +- Closes with a genuine thank-you and call-to-action + +**Technique to copy:** Use directory steps as the skeleton of an onboarding tour — they teach structure without requiring the author to explain every file. + +```json +{ + "directory": "src/_data", + "description": "This folder contains the **data files** for the site. Think of them as a lightweight database — YAML files that power the resource listings, posts index, and nav." +} +``` + +--- + +## github/codespaces-codeql — The most technically complete example + +**Tour file:** https://github.com/github/codespaces-codeql/blob/main/.tours/codeql-tutorial.tour +**Persona:** Security engineer / concept learner +**Steps:** 12 · **Depth:** Standard + +**What makes it good:** +- `isPrimary: true` — auto-launches when the Codespace opens +- `commands` array to run real VS Code commands mid-tour: the tour literally executes `codeQL.runQuery` when the reader arrives at that step +- `view` property to switch the sidebar panel (`"view": "codeQLDatabases"`) +- `pattern` instead of `line` for resilient matching: `"pattern": "import tutorial.*"` +- `selection` to highlight the exact `select` clause in a query file + +**This is the canonical reference for `commands`, `view`, and `pattern`.** + +```json +{ + "file": "tutorial.ql", + "pattern": "import tutorial.*", + "view": "codeQLDatabases", + "commands": ["codeQL.setDefaultTourDatabase", "codeQL.runQuery"], + "title": "Run your first query", + "description": "Click the **▶ Run** button above. The results appear in the CodeQL Query Results panel." +} +``` + +--- + +## github/codespaces-learn-with-me — Minimal interactive tutorial + +**Tour file:** https://github.com/github/codespaces-learn-with-me/blob/main/.tours/main.tour +**Persona:** Total beginner +**Steps:** 4 · **Depth:** Quick + +**What makes it good:** +- Only 4 steps — proves that less is more for quick/vibecoder personas +- `isPrimary: true` for auto-launch +- Each step tells the reader to **do something** (edit a string, change a color) — not just read +- Ends with a tangible outcome: "your page is live" + +**Technique to copy:** For quick/vibecoder tours, cut mercilessly. Four steps that drive action beat twelve that explain everything. + +--- + +## blackgirlbytes/copilot-todo-list — 28-step interactive tutorial + +**Tour file:** https://github.com/blackgirlbytes/copilot-todo-list/blob/main/.tours/main.tour +**Persona:** Concept learner / hands-on tutorial +**Steps:** 28 · **Depth:** Deep + +**What makes it good:** +- Uses **content-only checkpoint steps** (no `file` key) as progress milestones: "Check out your page! 🎉" and "Try it out!" between coding tasks +- Terminal inline commands in descriptions: `>> npm install uuid; npm install styled-components` +- Each file step shows the exact code the user should accept, in a markdown code fence, so they know the expected output + +**Technique to copy:** Checkpoint steps (content-only, milestone title) break up long tours and give the reader a sense of progress. + +```json +{ + "title": "Check out your page! 🎉", + "description": "Open the **Simple Browser** tab to see your to-do list. You should see all three tasks rendering from your data array.\n\nOnce you're happy with it, continue to add interactivity." +} +``` + +--- + +## lucasjellema/cloudnative-on-oci-2021 — Multi-tour architecture series + +**Tour files:** +- https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/function-tweet-retriever.tour +- https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/oci-and-infrastructure-as-code.tour +- https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/build-and-deployment-pipeline-function-tweet-retriever.tour + +**Persona:** Platform engineer / architect +**Steps:** 12 per tour · **Depth:** Standard + +**What makes it good:** +- Three separate tours for three separate concerns (function code, IaC, CI/CD pipeline) — each standalone but linked via `nextTour` +- `selection` coordinates used heavily in Terraform files where a block (not a single line) is the point +- Steps include markdown links to official OCI documentation inline +- Designed to be browsed via `vscode.dev/github.com/...` without cloning + +**Technique to copy:** For complex systems, write one tour per layer and chain them with `nextTour`. Don't try to cover infrastructure + application code + CI/CD in one tour. + +--- + +## SeleniumHQ/selenium — Monorepo build system onboarding + +**Tour files:** +- `.tours/bazel.tour` — Bazel workspace and build target orientation +- `.tours/building-and-testing-the-python-bindings.tour` — Python bindings BUILD.bazel walkthrough + +**Persona:** External contributor (build system focus) +**Steps:** ~10 per tour + +**What makes it good:** +- Targets a non-obvious entry point — not the product code but the build system +- Proves that "contributor onboarding" tours don't have to start with `main()` — they start with whatever is confusing about this specific repo +- Used in a large, mature OSS project at scale + +--- + +## Technique quick-reference + +| Feature | When to use | Real example | +|---------|-------------|-------------| +| `isPrimary: true` | Auto-launch tour when repo opens (Codespace, vscode.dev) | codespaces-learn-with-me, codespaces-codeql | +| `commands: [...]` | Run a VS Code command when reader arrives at this step | codespaces-codeql (`codeQL.runQuery`) | +| `view: "terminal"` | Switch VS Code sidebar/panel at this step | codespaces-codeql (`codeQLDatabases`) | +| `pattern: "regex"` | Match by line content, not number — use for volatile files | codespaces-codeql | +| `selection: {start, end}` | Highlight a block (function body, config section, type def) | a11yproject, oci-2021, codespaces-codeql | +| `directory: "path/"` | Orient to a folder without reading every file | a11yproject, codespaces-codeql | +| `uri: "https://..."` | Link to PR, issue, RFC, ADR, external doc | Any PR review tour | +| `nextTour: "Title"` | Chain tours in a series | oci-2021 (3-part series) | +| Checkpoint steps (content-only) | Progress milestones in long interactive tours | copilot-todo-list | +| `>> command` in description | Terminal inline command link in VS Code | copilot-todo-list | +| Embedded image in description | Architecture diagrams, screenshots | microsoft/codetour | + +--- + +## Discover more real tours on GitHub + +**Search all `.tour` files on GitHub:** +https://github.com/search?q=path%3A**%2F*.tour+&type=code + +This search returns every `.tour` file committed to a public GitHub repo. Use it to: +- Find tours for repos in the same language/framework as the one you're working on +- Study how other authors handle the same personas or step types +- Look up how a specific field (`commands`, `selection`, `pattern`) is used in the wild + +Filter by language or keyword to narrow results — e.g. add `language:TypeScript` or `fastapi` to the query. + +--- + +## Further reading + +- **DEV Community — "Onboard your codebase with CodeTour"**: https://dev.to/tobiastimm/onboard-your-codebase-with-codetour-2jc8 +- **Coder Blog — "Onboard to new projects faster with CodeTour"**: https://coder.com/blog/onboard-to-new-projects-faster-with-codetour +- **Microsoft Tech Community — Educator Developer Blog**: https://techcommunity.microsoft.com/blog/educatordeveloperblog/codetour-vscode-extension-allows-you-to-produce-interactive-guides-assessments-a/1274297 +- **AMIS Technology Blog — vscode.dev + CodeTour**: https://technology.amis.nl/software-development/visual-studio-code-the-code-tours-extension-for-in-context-and-interactive-readme/ +- **CodeTour GitHub Topics**: https://github.com/topics/codetour diff --git a/.github/skills/code-tour/scripts/generate_from_docs.py b/.github/skills/code-tour/scripts/generate_from_docs.py new file mode 100644 index 00000000..4c90c68e --- /dev/null +++ b/.github/skills/code-tour/scripts/generate_from_docs.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Generate a tour skeleton from repo documentation (README, CONTRIBUTING, docs/). + +Reads README.md (and optionally CONTRIBUTING.md, docs/) to extract: + - File and directory references + - Architecture / structure sections + - Setup instructions (becomes an orientation step) + - External links (becomes uri steps) + +Outputs a skeleton .tour JSON that the code-tour skill fills in with descriptions. +The skill reads this skeleton and enriches it — it does NOT replace the skill's judgment. + +Usage: + python generate_from_docs.py [--repo-root ] [--persona ] [--output ] + +Examples: + python generate_from_docs.py + python generate_from_docs.py --persona new-joiner --output .tours/from-readme.tour + python generate_from_docs.py --repo-root /path/to/repo --persona vibecoder +""" + +import json +import re +import sys +import os +from pathlib import Path +from typing import Optional + + +# ── Markdown extraction helpers ────────────────────────────────────────────── + +# Matches inline code that looks like a file/directory path +_CODE_PATH = re.compile(r"`([^`]{2,80})`") +# Matches headings +_HEADING = re.compile(r"^(#{1,3})\s+(.+)$", re.MULTILINE) +# Matches markdown links: [text](url) +_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)]+)\)") +# Patterns that suggest a path (contains / or . with extension) +_LOOKS_LIKE_PATH = re.compile(r"^\.?[\w\-]+(/[\w\-\.]+)+$|^\./|^[\w]+\.[a-z]{1,5}$") +# Architecture / structure section keywords +_STRUCT_KEYWORDS = re.compile( + r"\b(structure|architecture|layout|overview|directory|folder|module|component|" + r"design|system|organization|getting.started|quick.start|setup|installation)\b", + re.IGNORECASE, +) + + +def _extract_paths_from_text(text: str, repo_root: Path) -> list[str]: + """Extract inline code that looks like real file/directory paths.""" + candidates = _CODE_PATH.findall(text) + found = [] + for c in candidates: + c = c.strip().lstrip("./") + if not c: + continue + if not _LOOKS_LIKE_PATH.match(c) and "/" not in c and "." not in c: + continue + # check if path actually exists + full = repo_root / c + if full.exists(): + found.append(c) + return found + + +def _extract_external_links(text: str) -> list[tuple[str, str]]: + """Extract [label](url) pairs for URI steps.""" + links = _LINK.findall(text) + # filter out image links and very generic anchors + return [ + (label, url) + for label, url in links + if not url.endswith((".png", ".jpg", ".gif", ".svg")) + and label.lower() not in ("here", "this", "link", "click", "see") + ] + + +def _split_into_sections(text: str) -> list[tuple[str, str]]: + """Split markdown into (heading, body) pairs.""" + headings = list(_HEADING.finditer(text)) + sections = [] + for i, m in enumerate(headings): + heading = m.group(2).strip() + start = m.end() + end = headings[i + 1].start() if i + 1 < len(headings) else len(text) + body = text[start:end].strip() + sections.append((heading, body)) + return sections + + +def _is_structure_section(heading: str) -> bool: + return bool(_STRUCT_KEYWORDS.search(heading)) + + +# ── Step builders ───────────────────────────────────────────────────────────── + +def _make_content_step(title: str, hint: str) -> dict: + return { + "title": title, + "description": f"[TODO: {hint}]", + } + + +def _make_file_step(path: str, hint: str = "") -> dict: + step = { + "file": path, + "title": f"[TODO: title for {path}]", + "description": f"[TODO: {hint or 'explain this file for the persona'}]", + } + return step + + +def _make_dir_step(path: str, hint: str = "") -> dict: + return { + "directory": path, + "title": f"[TODO: title for {path}/]", + "description": f"[TODO: {hint or 'explain what lives here'}]", + } + + +def _make_uri_step(url: str, label: str) -> dict: + return { + "uri": url, + "title": label, + "description": "[TODO: explain why this link is relevant and what the reader should notice]", + } + + +# ── Core generator ──────────────────────────────────────────────────────────── + +def generate_skeleton(repo_root: str = ".", persona: str = "new-joiner") -> dict: + repo = Path(repo_root).resolve() + + # ── Read documentation files ───────────────────────────────────────── + doc_files = ["README.md", "readme.md", "Readme.md"] + extra_docs = ["CONTRIBUTING.md", "ARCHITECTURE.md", "docs/architecture.md", "docs/README.md"] + + readme_text = "" + for name in doc_files: + p = repo / name + if p.exists(): + readme_text = p.read_text(errors="replace") + break + + extra_texts = [] + for name in extra_docs: + p = repo / name + if p.exists(): + extra_texts.append((name, p.read_text(errors="replace"))) + + all_text = readme_text + "\n".join(t for _, t in extra_texts) + + # ── Collect steps ───────────────────────────────────────────────────── + steps = [] + seen_paths: set[str] = set() + + # 1. Intro step + steps.append( + _make_content_step( + "Welcome", + f"Introduce the repo: what it does, who this {persona} tour is for, what they'll understand after finishing.", + ) + ) + + # 2. Parse README sections + if readme_text: + sections = _split_into_sections(readme_text) + for heading, body in sections: + # structure / architecture sections → directory steps + if _is_structure_section(heading): + paths = _extract_paths_from_text(body, repo) + for p in paths: + if p in seen_paths: + continue + seen_paths.add(p) + full = repo / p + if full.is_dir(): + steps.append(_make_dir_step(p, f"mentioned under '{heading}' in README")) + elif full.is_file(): + steps.append(_make_file_step(p, f"mentioned under '{heading}' in README")) + + # 3. Scan all text for file/dir references not yet captured + all_paths = _extract_paths_from_text(all_text, repo) + for p in all_paths: + if p in seen_paths: + continue + seen_paths.add(p) + full = repo / p + if full.is_dir(): + steps.append(_make_dir_step(p)) + elif full.is_file(): + steps.append(_make_file_step(p)) + + # 4. If very few file steps found, fall back to top-level directory scan + file_and_dir_steps = [s for s in steps if "file" in s or "directory" in s] + if len(file_and_dir_steps) < 3: + # add top-level directories + for item in sorted(repo.iterdir()): + if item.name.startswith(".") or item.name in ("node_modules", "__pycache__", ".git"): + continue + rel = str(item.relative_to(repo)) + if rel in seen_paths: + continue + seen_paths.add(rel) + if item.is_dir(): + steps.append(_make_dir_step(rel, "top-level directory")) + elif item.is_file() and item.suffix in (".ts", ".js", ".py", ".go", ".rs", ".java", ".rb"): + steps.append(_make_file_step(rel, "top-level source file")) + + # 5. URI steps from external links in README + links = _extract_external_links(readme_text) + # Only include links that look like architecture / design references + for label, url in links[:3]: # cap at 3 to avoid noise + steps.append(_make_uri_step(url, label)) + + # 6. Closing step + steps.append( + _make_content_step( + "What to Explore Next", + "Summarize what the reader now understands. List 2–3 follow-up tours they should read next.", + ) + ) + + # Deduplicate steps by (file/directory/uri key) + seen_keys: set = set() + deduped = [] + for s in steps: + key = s.get("file") or s.get("directory") or s.get("uri") or s.get("title") + if key in seen_keys: + continue + seen_keys.add(key) + deduped.append(s) + + return { + "$schema": "https://aka.ms/codetour-schema", + "title": f"[TODO: descriptive title for {persona} tour]", + "description": f"[TODO: one sentence — who this is for and what they'll understand]", + "_skeleton_generated_by": "generate_from_docs.py", + "_instructions": ( + "This is a skeleton. Fill in every [TODO: ...] with real content. " + "Read each referenced file before writing its description. " + "Remove this _skeleton_generated_by and _instructions field before saving." + ), + "steps": deduped, + } + + +def main(): + args = sys.argv[1:] + if "--help" in args or "-h" in args: + print(__doc__) + sys.exit(0) + + repo_root = "." + persona = "new-joiner" + output: Optional[str] = None + + i = 0 + while i < len(args): + if args[i] == "--repo-root" and i + 1 < len(args): + repo_root = args[i + 1] + i += 2 + elif args[i] == "--persona" and i + 1 < len(args): + persona = args[i + 1] + i += 2 + elif args[i] == "--output" and i + 1 < len(args): + output = args[i + 1] + i += 2 + else: + i += 1 + + skeleton = generate_skeleton(repo_root, persona) + out_json = json.dumps(skeleton, indent=2) + + if output: + Path(output).parent.mkdir(parents=True, exist_ok=True) + Path(output).write_text(out_json) + print(f"✅ Skeleton written to {output}") + print(f" {len(skeleton['steps'])} steps generated from docs") + print(f" Fill in all [TODO: ...] entries before sharing") + else: + print(out_json) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/code-tour/scripts/validate_tour.py b/.github/skills/code-tour/scripts/validate_tour.py new file mode 100644 index 00000000..605e1a2e --- /dev/null +++ b/.github/skills/code-tour/scripts/validate_tour.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +CodeTour validator — bundled with the code-tour skill. + +Checks a .tour file for: + - Valid JSON + - Required fields (title, steps, description per step) + - File paths that actually exist in the repo + - Line numbers within file bounds + - Selection ranges within file bounds + - Directory paths that exist + - Pattern regexes that compile AND match at least one line + - URI format (must start with https://) + - nextTour matches an existing tour title in .tours/ + - Content-only step count (max 2 recommended) + - Narrative arc (first step should orient, last step should close) + +Usage: + python validate_tour.py [--repo-root ] + +Examples: + python validate_tour.py .tours/new-joiner.tour + python validate_tour.py .tours/new-joiner.tour --repo-root /path/to/repo +""" + +import json +import re +import sys +import os +from pathlib import Path + + +RESET = "\033[0m" +RED = "\033[31m" +YELLOW = "\033[33m" +GREEN = "\033[32m" +BOLD = "\033[1m" +DIM = "\033[2m" + + +def _line_count(path: Path) -> int: + try: + with open(path, errors="replace") as f: + return sum(1 for _ in f) + except Exception: + return 0 + + +def _file_content(path: Path) -> str: + try: + return path.read_text(errors="replace") + except Exception: + return "" + + +def validate_tour(tour_path: str, repo_root: str = ".") -> dict: + repo = Path(repo_root).resolve() + errors = [] + warnings = [] + info = [] + + # ── 1. JSON validity ──────────────────────────────────────────────────── + try: + with open(tour_path, errors="replace") as f: + tour = json.load(f) + except json.JSONDecodeError as e: + return { + "passed": False, + "errors": [f"Invalid JSON: {e}"], + "warnings": [], + "info": [], + "stats": {}, + } + except FileNotFoundError: + return { + "passed": False, + "errors": [f"File not found: {tour_path}"], + "warnings": [], + "info": [], + "stats": {}, + } + + # ── 2. Required top-level fields ──────────────────────────────────────── + if "title" not in tour: + errors.append("Missing required field: 'title'") + if "steps" not in tour: + errors.append("Missing required field: 'steps'") + return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}} + + steps = tour["steps"] + if not isinstance(steps, list): + errors.append("'steps' must be an array") + return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}} + + if len(steps) == 0: + errors.append("Tour has no steps") + return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}} + + # ── 3. Tour-level optional fields ─────────────────────────────────────── + if "nextTour" in tour: + tours_dir = Path(tour_path).parent + next_title = tour["nextTour"] + found_next = False + for tf in tours_dir.glob("*.tour"): + if tf.resolve() == Path(tour_path).resolve(): + continue + try: + other = json.loads(tf.read_text()) + if other.get("title") == next_title: + found_next = True + break + except Exception: + pass + if not found_next: + warnings.append( + f"nextTour '{next_title}' — no .tour file in .tours/ has a matching title" + ) + + # ── 4. Per-step validation ─────────────────────────────────────────────── + content_only_count = 0 + file_step_count = 0 + dir_step_count = 0 + uri_step_count = 0 + + for i, step in enumerate(steps): + label = f"Step {i + 1}" + if "title" in step: + label += f" — {step['title']!r}" + + # description required on every step + if "description" not in step: + errors.append(f"{label}: Missing required field 'description'") + + has_file = "file" in step + has_dir = "directory" in step + has_uri = "uri" in step + has_selection = "selection" in step + + if not has_file and not has_dir and not has_uri: + content_only_count += 1 + + # ── file ────────────────────────────────────────────────────────── + if has_file: + file_step_count += 1 + raw_path = step["file"] + + # must be relative — no leading slash, no ./ + if raw_path.startswith("/"): + errors.append(f"{label}: File path must be relative (no leading /): {raw_path!r}") + elif raw_path.startswith("./"): + warnings.append(f"{label}: File path should not start with './': {raw_path!r}") + + file_path = repo / raw_path + if not file_path.exists(): + errors.append(f"{label}: File does not exist: {raw_path!r}") + elif not file_path.is_file(): + errors.append(f"{label}: Path is not a file: {raw_path!r}") + else: + lc = _line_count(file_path) + + # line number + if "line" in step: + ln = step["line"] + if not isinstance(ln, int): + errors.append(f"{label}: 'line' must be an integer, got {ln!r}") + elif ln < 1: + errors.append(f"{label}: Line number must be >= 1, got {ln}") + elif ln > lc: + errors.append( + f"{label}: Line {ln} exceeds file length ({lc} lines): {raw_path!r}" + ) + + # selection + if has_selection: + sel = step["selection"] + start = sel.get("start", {}) + end = sel.get("end", {}) + s_line = start.get("line", 0) + e_line = end.get("line", 0) + if s_line > lc: + errors.append( + f"{label}: Selection start line {s_line} exceeds file length ({lc})" + ) + if e_line > lc: + errors.append( + f"{label}: Selection end line {e_line} exceeds file length ({lc})" + ) + if s_line > e_line: + errors.append( + f"{label}: Selection start ({s_line}) is after end ({e_line})" + ) + + # pattern + if "pattern" in step: + try: + compiled = re.compile(step["pattern"], re.MULTILINE) + content = _file_content(file_path) + if not compiled.search(content): + errors.append( + f"{label}: Pattern {step['pattern']!r} matches nothing in {raw_path!r}" + ) + except re.error as e: + errors.append(f"{label}: Invalid regex pattern: {e}") + + # ── directory ───────────────────────────────────────────────────── + if has_dir: + dir_step_count += 1 + raw_dir = step["directory"] + dir_path = repo / raw_dir + if not dir_path.exists(): + errors.append(f"{label}: Directory does not exist: {raw_dir!r}") + elif not dir_path.is_dir(): + errors.append(f"{label}: Path is not a directory: {raw_dir!r}") + + # ── uri ─────────────────────────────────────────────────────────── + if has_uri: + uri_step_count += 1 + uri = step["uri"] + if not uri.startswith("https://") and not uri.startswith("http://"): + warnings.append(f"{label}: URI should start with https://: {uri!r}") + + # ── commands ────────────────────────────────────────────────────── + if "commands" in step: + if not isinstance(step["commands"], list): + errors.append(f"{label}: 'commands' must be an array") + else: + for cmd in step["commands"]: + if not isinstance(cmd, str): + errors.append(f"{label}: Each command must be a string, got {cmd!r}") + + # ── 5. Content-only step count ────────────────────────────────────────── + if content_only_count > 2: + warnings.append( + f"{content_only_count} content-only steps (no file/dir/uri). " + f"Recommended max: 2 (intro + closing)." + ) + + # ── 6. Narrative arc checks ───────────────────────────────────────────── + first = steps[0] + last = steps[-1] + first_is_orient = "file" not in first and "directory" not in first and "uri" not in first + last_is_closing = "file" not in last and "directory" not in last and "uri" not in last + + if not first_is_orient and "directory" not in first: + info.append( + "First step is a file/uri step — consider starting with a content or directory " + "orientation step." + ) + if not last_is_closing: + info.append( + "Last step is not a content step — consider ending with a closing/summary step." + ) + + stats = { + "total_steps": len(steps), + "file_steps": file_step_count, + "directory_steps": dir_step_count, + "content_steps": content_only_count, + "uri_steps": uri_step_count, + } + + return { + "passed": len(errors) == 0, + "errors": errors, + "warnings": warnings, + "info": info, + "stats": stats, + } + + +def print_report(tour_path: str, result: dict) -> None: + title = f"{BOLD}{tour_path}{RESET}" + print(f"\n{title}") + print("─" * 60) + + stats = result.get("stats", {}) + if stats: + parts = [ + f"{stats.get('total_steps', 0)} steps", + f"{stats.get('file_steps', 0)} file", + f"{stats.get('directory_steps', 0)} dir", + f"{stats.get('content_steps', 0)} content", + f"{stats.get('uri_steps', 0)} uri", + ] + print(f"{DIM} {' · '.join(parts)}{RESET}") + + errors = result.get("errors", []) + warnings = result.get("warnings", []) + info = result.get("info", []) + + for e in errors: + print(f" {RED}✗ {e}{RESET}") + for w in warnings: + print(f" {YELLOW}⚠ {w}{RESET}") + for i in info: + print(f" {DIM}ℹ {i}{RESET}") + + if result["passed"] and not warnings: + print(f" {GREEN}✓ All checks passed{RESET}") + elif result["passed"]: + print(f" {GREEN}✓ Passed{RESET} {YELLOW}(with warnings){RESET}") + else: + print(f" {RED}✗ Failed — {len(errors)} error(s){RESET}") + + print() + + +def main(): + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help"): + print(__doc__) + sys.exit(0) + + repo_root = "." + tour_files = [] + + i = 0 + while i < len(args): + if args[i] == "--repo-root" and i + 1 < len(args): + repo_root = args[i + 1] + i += 2 + else: + tour_files.append(args[i]) + i += 1 + + if not tour_files: + # validate all tours in .tours/ + tours_dir = Path(".tours") + if tours_dir.exists(): + tour_files = [str(p) for p in sorted(tours_dir.glob("*.tour"))] + if not tour_files: + print("No .tour files found. Pass a file path or run from a repo with a .tours/ directory.") + sys.exit(1) + + all_passed = True + for tf in tour_files: + result = validate_tour(tf, repo_root) + print_report(tf, result) + if not result["passed"]: + all_passed = False + + sys.exit(0 if all_passed else 1) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/pr-review/SKILL.md b/.github/skills/pr-review/SKILL.md new file mode 100644 index 00000000..8c0f2ff6 --- /dev/null +++ b/.github/skills/pr-review/SKILL.md @@ -0,0 +1,37 @@ +--- +name: pr-review +description: Team-standard PR review checklist +--- + +# PR Review + +Review code changes against team standards: + +## Security Checklist +- [ ] No hardcoded secrets or API keys +- [ ] Input validation on all user data +- [ ] No bare except clauses +- [ ] No sensitive data in logs + +## Code Quality +- [ ] Functions under 50 lines +- [ ] No print statements in production code +- [ ] Type hints on public functions +- [ ] Context managers for file I/O +- [ ] No TODOs without issue references + +## Testing +- [ ] New code has tests +- [ ] Edge cases covered +- [ ] No skipped tests without explanation + +## Documentation +- [ ] API changes documented +- [ ] Breaking changes noted +- [ ] README updated if needed + +## Output Format +Provide results as: +- ✅ PASS: Items that look good +- ⚠️ WARN: Items that could be improved +- ❌ FAIL: Items that must be fixed before merge diff --git a/.github/skills/quick-review/SKILL.md b/.github/skills/quick-review/SKILL.md new file mode 100644 index 00000000..734883f1 --- /dev/null +++ b/.github/skills/quick-review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: quick-review +description: Quick review code quality +--- + +# Quick review + +Quick review code changes for basic quality checks: + +## Code Quality + +- [ ] Type hints on functions +- [ ] No bare except clauses +- [ ] No unclear variable names + +## Output Format + +Provide results as: +- ✅ PASS: Items that look good +- ⚠️ WARN: Items that could be improved +- ❌ FAIL: Items that must be fixed before merge \ No newline at end of file diff --git a/.github/skills/security-audit/SKILL.md b/.github/skills/security-audit/SKILL.md new file mode 100644 index 00000000..3fbadd7b --- /dev/null +++ b/.github/skills/security-audit/SKILL.md @@ -0,0 +1,38 @@ +--- +name: security-audit +description: Security-focused code review checking OWASP (Open Web Application Security Project) Top 10 vulnerabilities +--- + +# Security Audit + +Perform a security audit checking for: + +## Injection Vulnerabilities +- SQL injection (string concatenation in queries) +- Command injection (unsanitized shell commands) +- LDAP injection +- XPath injection + +## Authentication Issues +- Hardcoded credentials +- Weak password requirements +- Missing rate limiting +- Session management flaws + +## Sensitive Data +- Plaintext passwords +- API keys in code +- Logging sensitive information +- Missing encryption + +## Access Control +- Missing authorization checks +- Insecure direct object references +- Path traversal vulnerabilities + +## Output +For each issue found, provide: +1. File and line number +2. Vulnerability type +3. Severity (CRITICAL/HIGH/MEDIUM/LOW) +4. Recommended fix \ No newline at end of file diff --git a/.github/workflows/co-op-translator.yml b/.github/workflows/co-op-translator.yml new file mode 100644 index 00000000..edff8cc4 --- /dev/null +++ b/.github/workflows/co-op-translator.yml @@ -0,0 +1,130 @@ +name: Co-op Translator + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "**/*.md" + - "**/*.png" + - "**/*.jpg" + - "**/*.jpeg" + - "**/*.webp" + - "!translations/**" + - "!translated_images/**" + - "!.github/**" + - "!samples/skills/**" + +permissions: + contents: write + pull-requests: write + +jobs: + translate-content: + name: Translate Markdown and Images + runs-on: ubuntu-latest + timeout-minutes: 90 + + env: + TRANSLATION_LANGUAGES: "es" + PYTHONIOENCODING: utf-8 + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} + AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} + AZURE_OPENAI_MODEL_NAME: ${{ secrets.AZURE_OPENAI_MODEL_NAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }} + AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }} + AZURE_AI_SERVICE_API_KEY: ${{ secrets.AZURE_AI_SERVICE_API_KEY }} + AZURE_AI_SERVICE_ENDPOINT: ${{ secrets.AZURE_AI_SERVICE_ENDPOINT }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_ORG_ID: ${{ secrets.OPENAI_ORG_ID }} + OPENAI_CHAT_MODEL_ID: ${{ secrets.OPENAI_CHAT_MODEL_ID }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Co-op Translator + run: | + python -m pip install --upgrade pip + python -m pip install co-op-translator + + - name: Translate Markdown + run: | + for attempt in 1 2 3; do + if translate -l "$TRANSLATION_LANGUAGES" -md -y -s --repo-url "https://github.com/github/copilot-cli-for-beginners.git"; then + exit 0 + fi + echo "Markdown translation attempt $attempt failed." + if [ "$attempt" -lt 3 ]; then + sleep 30 + fi + done + exit 1 + + - name: Translate images + run: | + for attempt in 1 2 3; do + if translate -l "$TRANSLATION_LANGUAGES" -img -y -s --repo-url "https://github.com/github/copilot-cli-for-beginners.git"; then + exit 0 + fi + echo "Image translation attempt $attempt failed." + if [ "$attempt" -lt 3 ]; then + sleep 30 + fi + done + exit 1 + + - name: Normalize and review translations + run: | + migrate-links -l "$TRANSLATION_LANGUAGES" -y + node .github/scripts/fix-translated-markdown.js "$TRANSLATION_LANGUAGES" + if command -v co-op-review >/dev/null 2>&1; then + co-op-review -l "$TRANSLATION_LANGUAGES" --format github + else + echo "co-op-review is not available in the installed Co-op Translator package; skipping." + fi + + - name: Remove excluded translations + run: | + for language in $TRANSLATION_LANGUAGES; do + rm -rf \ + "translations/$language/.github" \ + "translations/$language/samples/skills" \ + "translated_images/$language/.github" \ + "translated_images/$language/samples/skills" + done + + - name: Upload Co-op Translator logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: co-op-translator-logs + path: logs/ + if-no-files-found: ignore + + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GH_AW_GITHUB_TOKEN }} + commit-message: "Update translations via Co-op Translator" + title: "Update translations via Co-op Translator" + body: | + This PR updates Markdown and image translations generated by Co-op Translator. + + Generated content is available in the `translations/` and `translated_images/` directories. + branch: update-translations + base: main + labels: translation, automated-pr + delete-branch: true + add-paths: | + translations/ + translated_images/ diff --git a/.github/workflows/translation-polisher.lock.yml b/.github/workflows/translation-polisher.lock.yml new file mode 100644 index 00000000..bdc752b1 --- /dev/null +++ b/.github/workflows/translation-polisher.lock.yml @@ -0,0 +1,1327 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"cbc9f85ce905d99f4478b7d93d8191a4d9b0d24e13640ae360044e3b41ed5a9e","compiler_version":"v0.68.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9"},{"repo":"actions/upload-artifact","sha":"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f","version":"v7"},{"repo":"github/gh-aw-actions/setup","sha":"2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc","version":"v0.68.1"}]} +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.68.1). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Reviews Co-op Translator pull requests and polishes generated translations without changing source content. +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_CI_TRIGGER_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 +# - actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 +# - github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + +name: "Translation Polisher" +"on": + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + inputs: + aw_context: + default: "" + description: Agent caller context (used internally by Agentic Workflows). + required: false + type: string + workflow_run: + # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation + branches: + - main + types: + - completed + workflows: + - Co-op Translator + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}" + cancel-in-progress: true + +run-name: "Translation Polisher" + +jobs: + activation: + needs: pre_activation + # zizmor: ignore[dangerous-triggers] - workflow_run trigger is secured with role and fork validation + if: > + (needs.pre_activation.outputs.activated == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id)) && + (github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id && + (!(github.event.workflow_run.repository.fork))) + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + outputs: + body: ${{ steps.sanitized.outputs.body }} + comment_id: "" + comment_repo: "" + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + text: ${{ steps.sanitized.outputs.text }} + title: ${{ steps.sanitized.outputs.title }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" + GH_AW_INFO_CLI_VERSION: "v0.68.1" + GH_AW_INFO_WORKFLOW_NAME: "Translation Polisher" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.25.18" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_WORKFLOW_FILE: "translation-polisher.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_COMPILED_VERSION: "v0.68.1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Compute current body text + id: sanitized + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/compute_text.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_007b898c8d34eb6e_EOF' + + GH_AW_PROMPT_007b898c8d34eb6e_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_007b898c8d34eb6e_EOF' + + Tools: update_pull_request, add_labels, push_to_pull_request_branch, missing_tool, missing_data, noop + GH_AW_PROMPT_007b898c8d34eb6e_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" + cat << 'GH_AW_PROMPT_007b898c8d34eb6e_EOF' + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + - **checkouts**: The following repositories have been checked out and are available in the workspace: + - `$GITHUB_WORKSPACE` → `__GH_AW_GITHUB_REPOSITORY__` (cwd) [full history, all branches available as remote-tracking refs] [additional refs fetched: *] + - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it without proper authentication. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches). + + + GH_AW_PROMPT_007b898c8d34eb6e_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_007b898c8d34eb6e_EOF' + + {{#runtime-import .github/workflows/translation-polisher.md}} + GH_AW_PROMPT_007b898c8d34eb6e_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: activation + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/github_rate_limits.jsonl + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: translationpolisher + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Set runtime paths + id: set-runtime-paths + run: | + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + - name: Fetch additional refs + env: + GH_AW_FETCH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + header=$(printf "x-access-token:%s" "${GH_AW_FETCH_TOKEN}" | base64 -w 0) + git -c "http.extraheader=Authorization: Basic ${header}" fetch origin '+refs/heads/*:refs/remotes/origin/*' + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 ghcr.io/github/gh-aw-mcpg:v0.2.17 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + - name: Write Safe Outputs Config + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << GH_AW_SAFE_OUTPUTS_CONFIG_fa8fe511dd276b01_EOF + {"add_labels":{"allowed":["translation-polished"],"github-token":"${GH_AW_GITHUB_TOKEN}","max":1},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"push_to_pull_request_branch":{"allowed_files":["translations/**/*.md","translations/**/.co-op-translator.json"],"github-token":"${GH_AW_GITHUB_TOKEN}","if_no_changes":"ignore","labels":["translation","automated-pr"],"max":1,"max_patch_size":1024,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS"],"protected_files_policy":"allowed","protected_path_prefixes":[".github/",".agents/"],"target":"*"},"report_incomplete":{},"update_pull_request":{"allow_body":true,"allow_title":false,"github-token":"${GH_AW_GITHUB_TOKEN}","max":1,"target":"*"}} + GH_AW_SAFE_OUTPUTS_CONFIG_fa8fe511dd276b01_EOF + - name: Write Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "add_labels": " CONSTRAINTS: Maximum 1 label(s) can be added. Only these labels are allowed: [\"translation-polished\"].", + "push_to_pull_request_branch": " CONSTRAINTS: Maximum 1 push(es) can be made.", + "update_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be updated. Target: *." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueNumberOrTemporaryId": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "push_to_pull_request_branch": { + "defaultMax": 1, + "fields": { + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "pull_request_number": { + "issueOrPRNumber": true + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + }, + "update_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "draft": { + "type": "boolean" + }, + "operation": { + "type": "string", + "enum": [ + "replace", + "append", + "prepend" + ] + }, + "pull_request_number": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + }, + "customValidation": "requiresOneOf:title,body" + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.17' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_592a00f4d05d7e7b_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh" + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.32.0", + "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "repos,pull_requests" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_592a00f4d05d7e7b_EOF + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Clean git credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool github + # --allow-tool safeoutputs + # --allow-tool shell(cat) + # --allow-tool shell(date) + # --allow-tool shell(echo) + # --allow-tool shell(gh:*) + # --allow-tool shell(git add:*) + # --allow-tool shell(git branch:*) + # --allow-tool shell(git checkout:*) + # --allow-tool shell(git commit:*) + # --allow-tool shell(git merge:*) + # --allow-tool shell(git rm:*) + # --allow-tool shell(git status) + # --allow-tool shell(git switch:*) + # --allow-tool shell(git:*) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(ls) + # --allow-tool shell(node) + # --allow-tool shell(pwd) + # --allow-tool shell(sort) + # --allow-tool shell(tail) + # --allow-tool shell(uniq) + # --allow-tool shell(wc) + # --allow-tool shell(yq) + # --allow-tool write + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + # shellcheck disable=SC1003 + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(gh:*)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(git:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(node)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.68.1 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Detect inference access error + id: detect-inference-error + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh" + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,localhost,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle + if-no-files-found: ignore + - name: Upload firewall audit logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: firewall-audit-logs + path: | + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-translation-polisher" + cancel-in-progress: false + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Translation Polisher" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "false" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Translation Polisher" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Translation Polisher" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Translation Polisher" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "translation-polisher" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} + GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18 ghcr.io/github/gh-aw-firewall/squid:0.25.18 + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + WORKFLOW_NAME: "Translation Polisher" + WORKFLOW_DESCRIPTION: "Reviews Co-op Translator pull requests and polishes generated translations without changing source content." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + # shellcheck disable=SC1003 + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.68.1 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + + pre_activation: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + matched_command: '' + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_REQUIRED_ROLES: "admin,maintainer,write" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/translation-polisher" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_WORKFLOW_ID: "translation-polisher" + GH_AW_WORKFLOW_NAME: "Translation Polisher" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + push_commit_sha: ${{ steps.process_safe_outputs.outputs.push_commit_sha }} + push_commit_url: ${{ steps.process_safe_outputs.outputs.push_commit_url }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc # v0.68.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Checkout repository + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch') + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch') + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,localhost,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"translation-polished\"],\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN }}\",\"max\":1},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"translations/**/*.md\",\"translations/**/.co-op-translator.json\"],\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN }}\",\"if_no_changes\":\"ignore\",\"labels\":[\"translation\",\"automated-pr\"],\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"AGENTS.md\"],\"protected_files_policy\":\"allowed\",\"protected_path_prefixes\":[\".github/\",\".agents/\"],\"target\":\"*\"},\"report_incomplete\":{},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":false,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN }}\",\"max\":1,\"target\":\"*\"}}" + GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: safe-outputs-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore + diff --git a/.github/workflows/translation-polisher.md b/.github/workflows/translation-polisher.md new file mode 100644 index 00000000..64a3063c --- /dev/null +++ b/.github/workflows/translation-polisher.md @@ -0,0 +1,266 @@ +--- +name: "Translation Polisher" +description: "Reviews Co-op Translator pull requests and polishes generated translations without changing source content." +on: + workflow_run: + workflows: ["Co-op Translator"] + types: [completed] + branches: [main] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: +permissions: + contents: read + pull-requests: read + issues: read +engine: copilot +tools: + bash: ["gh", "git", "node"] + edit: + github: + toolsets: [repos, pull_requests] +checkout: + fetch-depth: 0 + fetch: ["*"] +network: defaults +safe-outputs: + allowed-domains: + - github.com + noop: + report-as-issue: false + add-labels: + allowed: [translation-polished] + max: 1 + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN }} + update-pull-request: + target: "*" + title: false + body: true + max: 1 + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN }} + push-to-pull-request-branch: + target: "*" + labels: [translation, automated-pr] + protected-files: allowed + allowed-files: + - "translations/**/*.md" + - "translations/**/.co-op-translator.json" + if-no-changes: "ignore" + max: 1 + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN }} +--- + +# Polish Co-op Translator PRs + +You are a translation editor for the **GitHub Copilot CLI for Beginners** course. Your job is to review and polish Markdown files generated by Co-op Translator, while preserving the repository's translation structure and source content. + +## Scope + +Only work on Co-op Translator pull requests. + +A pull request is in scope when all of these are true: + +1. The pull request title starts with `Update translations via Co-op Translator`. +2. The pull request has both labels: `translation` and `automated-pr`. +3. The pull request changes files under `translations/`. + +If this workflow is triggered by `workflow_run` or `workflow_dispatch`, find the current open pull request with head branch `update-translations`. If no matching pull request exists, stop with a no-op. + +If this workflow is triggered by `pull_request`, inspect the triggering pull request. If it is not in scope, stop with a no-op. + +## Loop prevention + +Before editing, inspect the pull request's latest commit, current diff, labels, and body. + +Stop with a no-op only if all of these are true: + +1. The translated Markdown already satisfies the quality checklist below and no file changes are needed. +2. The pull request body already contains an up-to-date managed `## Translation Quality Review` section with one grade row for every changed translated Markdown file. +3. Every existing grade in the managed review section is **A- or higher**. + +If the latest commit appears to be from this Translation Polisher workflow but the pull request body is missing the `## Translation Quality Review` section, do not edit files. Still review and grade the changed translated Markdown files and update the pull request body. + +If the pull request body already contains a managed `## Translation Quality Review` section and any row is graded **B+ or lower**, treat those files as required repair targets. Review and polish those files again before deciding whether to push changes or report blocking issues in the managed pull request body section. + +Do not add churn. If the translation is already good enough and the PR body already has current A- or higher grades, leave it unchanged. + +## Files you may change + +You may edit only translated Markdown files: + +- `translations/**/*.md` + +Exclude these generated translation paths from review, grading, and edits: + +- `translations/*/.github/**` +- `translations/*/samples/skills/**` + +Leave skill definition Markdown in English. + +Do not edit: + +- English source files +- `.co-op-translator.json` +- workflow files +- scripts +- sample source code outside translated Markdown + +## Required process + +1. Identify the target pull request number and head branch. +2. Compare each changed translated Markdown file with its corresponding English source file. + - Example: compare `translations/es/README.md` with `README.md`. + - Example: compare `translations/es/03-development-workflows/README.md` with `03-development-workflows/README.md`. +3. Focus on files changed by the pull request, not every translated file in the repository. Ignore excluded translation paths under `translations/*/.github/**` and `translations/*/samples/skills/**`. +4. If the pull request body already has a managed `## Translation Quality Review` section, identify files with grades below A- and repair those files first. +5. Preserve Markdown structure exactly unless a link or heading fix is required. +6. Apply the shared quality rules and the language quality profile for each target language present in the pull request. +7. Run an untranslated learner-facing text pass: + - Check headings, visible table headers, navigation tables, list labels, callout/admonition labels, and human-facing link labels. + - Translate leftover English when it is learner-facing prose. + - Preserve product names, commands, file paths, branch names, package names, URLs, badge URLs, code identifiers, and GitHub UI labels that learners must recognize. +8. Run the deterministic cleanup script after edits: + + ```bash + node .github/scripts/fix-translated-markdown.js "" + ``` + +9. Perform a final review of each changed target-language file against its English source file. Grade each file using A, A-, B+, B, B-, C, D, or F. +10. Continue improving the translation until every changed target-language file earns **A- or higher**. +11. If any changed target-language file remains below A- after reasonable polishing, do not push changes and do not add the `translation-polished` label. Update the pull request body with `Translation status: Needs polish` and include the blocking issues and current file grades in the managed review section. +12. Review your final diff. If it contains anything outside `translations/**/*.md` or Co-op metadata files named `translations/**/.co-op-translator.json`, revert those changes. +13. Push your changes to the target pull request branch using the safe output only when every changed target-language file is A- or higher. +14. Update the pull request body with a final per-file grade table using the instructions in **Pull request body update**. +15. Add the `translation-polished` label only when every changed target-language file is A- or higher. +16. Do not add a pull request comment. The managed pull request body section is the source of truth for review status, grades, and notes. + +## Safe output limits + +Emit each safe output type at most once: + +- At most one `push_to_pull_request_branch`. +- At most one `update_pull_request`. +- At most one `add_labels`, and only when the `translation-polished` label is not already present on the pull request. +- Do not emit `add_comment`; comments are not an allowed safe output for this workflow. + +Do not emit duplicate branch push, pull request update, label, or comment requests. If a label is already present, do not emit an `add_labels` request for it. If you emit `add_labels`, include the target pull request number as `item_number`. Put the quality summary and polishing summary in the single managed pull request body section. + +## Quality checklist + +For every translated Markdown file you edit: + +- Preserve all code blocks exactly unless the original English text inside the code block is instructional prose that should intentionally be translated. +- Preserve command names, file paths, package names, product names, URLs, and badge URLs. +- Preserve Markdown tables, lists, blockquotes, headings, and admonitions. +- Preserve links and image destinations. Translate only the human-facing link label when appropriate. +- Translate human-facing prose naturally for the target language. +- Translate visible headings, table headings, navigation labels, list labels, callout labels, and link labels when they are human-facing content. +- Keep the beginner-friendly tone of the English source. +- Avoid literal phrasing that sounds unnatural in the target language. +- Do not remove the Co-op Translator disclaimer. +- Do not edit translation metadata. + +## Final translation review rubric + +Before pushing, review each changed translated Markdown file against its corresponding English source and assign a grade. + +Grade **A- or higher** only when all of these are true: + +- The translation preserves the meaning, scope, warnings, and calls to action from the English source. +- The Markdown structure, links, images, headings, tables, badges, and code blocks are intact. +- Human-facing prose, navigation labels, table headings, and link labels are translated where appropriate. +- Product names, commands, file paths, URLs, package names, and UI labels are preserved when they should be. +- The text sounds natural to a technical learner in the target language, not like a literal sentence-by-sentence translation. +- Terminology is consistent within the file and across the same target language. +- The tone remains beginner-friendly, practical, and encouraging. + +Use **B+ or lower** if any visible learner-facing text remains unnecessarily in English, if phrasing is noticeably awkward, if terminology is inconsistent, or if important nuance is missing. Keep polishing until every changed translated Markdown file is A- or higher. + +## Language quality profiles + +Apply the profile only when that language is present in the pull request. + +### Shared rules for all languages + +- Preserve product names such as **GitHub Copilot CLI**, **GitHub Codespaces**, and **Azure AI Foundry** unless an official localized name is clearly standard in the target-language ecosystem. +- Preserve commands, code, file paths, URLs, badge URLs, package names, branch names, and repository names. +- Translate human-facing link labels, table headings, navigation labels, and instructional prose. +- Keep English technical terms only when they are common in the target language, are official UI labels, or are product/feature names. +- Prefer natural beginner-friendly phrasing over literal translation. +- Use consistent terminology within each file and across the same language. +- Do not over-localize acronyms or terms that target-language developers normally use in English. + +### Spanish (`es`) + +- Use clear, neutral Spanish for a broad technical audience. +- Prefer natural active voice over passive constructions. +- Localize beginner-facing concepts such as issue and pull request when clarity improves, but keep GitHub UI terms in English when they refer to the UI label. +- Keep common technical acronyms such as API. +- Avoid overly literal phrasing. For example, prefer natural wording such as `potenciar`, `colega experto`, and `donde se encuentra cada una` when the sentence context calls for it. + +### Korean (`ko`) + +- Use polite, clear technical Korean appropriate for educational documentation. +- Keep product names in English unless there is a clear official Korean name. +- Prefer commonly used Korean developer terminology for concepts, but do not translate CLI commands, file paths, Git branch names, package names, or GitHub UI labels that learners must recognize. +- Avoid overly formal or machine-translated sentence endings; keep instructions direct and approachable. + +### Japanese (`ja`) + +- Use clear technical Japanese with a polite instructional tone. +- Keep product names in English unless there is a clear official Japanese name. +- Prefer standard Japanese developer terms and natural sentence structure. +- Avoid overly literal English word order. +- Do not translate commands, file paths, Git branch names, package names, or GitHub UI labels that learners must recognize. + +### Simplified Chinese (`zh-CN`) + +- Use Simplified Chinese. +- Use clear mainland Chinese technical documentation style. +- Keep product names in English unless there is a clear official Simplified Chinese name. +- Avoid Taiwan/Hong Kong traditional terminology. +- Do not translate commands, file paths, Git branch names, package names, or GitHub UI labels that learners must recognize. + +## Pull request body update + +After the final review, update the pull request body with a managed translation-quality section. Replace only the managed block between these exact lowercase markers: + +```markdown + + +``` + +The body must include exactly one managed block and exactly one section inside that block with this heading: + +```markdown +## Translation Quality Review +``` + +If an older unmarked `## Translation Quality Review` section already exists, replace it with the marked block. Do not append duplicates. Do not change the marker casing. Do not place generated workflow footers, integrity notes, or unrelated comments inside the managed block. + +Do not use any other marker names or casing. In particular, never use `TRANSLATION-REVIEW-START`, `TRANSLATION-REVIEW-END`, `TRANSLATION-QUALITY-REVIEW-START`, or uppercase marker variants. + +Use this format: + +```markdown + +## Translation Quality Review + +**Translation status:** Accepted +**Files reviewed:** 34 total, 34 accepted, 0 needs polish + +| Language | File | Final grade | Notes | +|---|---|---:|---| +| es | `translations/es/README.md` | A- | Preserves structure and reads naturally after polish. | + +All changed translated Markdown files must be graded **A- or higher** before this PR is marked `translation-polished`. + +``` + +Use `Translation status: Accepted` only when every changed translated Markdown file is graded A- or higher. Otherwise use `Translation status: Needs polish`, include counts for total files, accepted files, and files that need polish, and keep the `translation-polished` label off the PR. + +Include one row for every changed translated Markdown file in the target pull request. Keep notes concise and specific. For below-threshold files, the note must state the highest-impact issue to fix. + +## Pull request comments + +Do not add pull request comments. Use only the managed `## Translation Quality Review` pull request body section for grades, notes, and status. diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..ab1483ab --- /dev/null +++ b/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "filesystem": { + "type": "local", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + "tools": ["*"] + }, + "context7": { + "type": "local", + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"], + "tools": ["*"] + } + } +} \ No newline at end of file diff --git a/04-agents-custom-instructions/README.md b/04-agents-custom-instructions/README.md index 8e4a7629..5321b172 100644 --- a/04-agents-custom-instructions/README.md +++ b/04-agents-custom-instructions/README.md @@ -446,6 +446,32 @@ For teams that want more granular control, split instructions into topic-specifi > 💡 **Note**: Instruction files work with any language. This example uses Python to match our course project, but you can create similar files for TypeScript, Go, Rust, or any technology your team uses. +#### Scoping Instructions with `applyTo` + +By default, an instruction file applies to every conversation. To limit it to specific file types, add an `applyTo` field in YAML frontmatter (the block between `---` markers at the very top of the file): + +```markdown +--- +applyTo: "**/*.py" +--- +# Python Standards +Always follow PEP 8 style conventions. +Use type hints in all function signatures. +``` + +With `applyTo: "**/*.py"`, Copilot only loads that instruction file when you are working with Python files. Instructions for Python style never clutter a conversation about, say, a Dockerfile or a SQL query. + +Here are some common patterns: + +| `applyTo` value | When it applies | +|---|---| +| `"**/*.py"` | Any Python file | +| `"**/*.{ts,tsx}"` | TypeScript and TSX files | +| `"tests/**"` | Any file inside a `tests/` folder | +| (no frontmatter) | Every conversation — the default | + +> 💡 **Tip**: Wrap the glob pattern in quotes (e.g., `"**/*.py"`) to ensure it is interpreted correctly across all operating systems and shells. + **Finding community instruction files**: Browse [github/awesome-copilot](https://github.com/github/awesome-copilot) for pre-made instruction files covering .NET, Angular, Azure, Python, Docker, and many more technologies. ### Disabling Custom Instructions diff --git a/README.md b/README.md index 4f2cd85f..72bdc8e5 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,6 @@ This course is designed for: - **Terminal users** who prefer keyboard-driven workflows over IDE integrations - **Teams looking to standardize** AI-assisted code review and development practices - - - GitHub Copilot Dev Days - Find or host an event - - - ## 🎯 What You'll Learn This hands-on course takes you from zero to productive with GitHub Copilot CLI. You'll work with a single Python book collection app throughout all chapters, progressively improving it using AI-assisted workflows. By the end, you'll confidently use AI to review code, generate tests, debug issues, and automate workflows: all from your terminal. diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index fdad6e4f..8fd778b9 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -2,7 +2,7 @@ from collections.abc import Callable from books import BookCollection -from utils import display_books, display_help +from utils import display_books, display_help, parse_publication_year def handle_list(collection: BookCollection) -> int: @@ -18,8 +18,12 @@ def handle_add(collection: BookCollection) -> int: author = input("Author: ").strip() year_str = input("Year: ").strip() + year, year_error = parse_publication_year(year_str) + if year_error is not None or year is None: + print(f"\nError: {year_error}\n") + return 1 + try: - year = int(year_str) if year_str else 0 collection.add_book(title, author, year) print("\nBook added successfully.\n") return 0 @@ -32,15 +36,17 @@ def handle_remove(collection: BookCollection) -> int: print("\nRemove a Book\n") title = input("Enter the title of the book to remove: ").strip() - if not title: - print("\nError: Title cannot be empty.\n") + try: + result = collection.remove_book(title) + except ValueError as e: + print(f"\nError: {e}\n") return 1 - if collection.remove_book(title): - print("\nBook removed successfully.\n") + if result.success: + print(f"\n{result.message}\n") return 0 - print("\nError: Book not found.\n") + print(f"\nError: {result.message}\n") return 1 diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 33282d43..f64499a6 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -1,4 +1,5 @@ from contextlib import contextmanager +from datetime import date import json import os from pathlib import Path @@ -36,6 +37,36 @@ def _normalize_required_text(value: str, field_name: str) -> str: return normalized_value +def _validate_publication_year(year: int) -> int: + """Validate that a publication year is realistic for the sample app. + + Args: + year (int): The publication year to validate. + + Returns: + int: The validated year when it is within the accepted range. + + Raises: + ValueError: If ``year`` is negative or later than the current year. + + Examples: + >>> _validate_publication_year(1965) + 1965 + >>> _validate_publication_year(-1) + Traceback (most recent call last): + ... + ValueError: Year cannot be negative. + """ + if year < 0: + raise ValueError("Year cannot be negative.") + + current_year = date.today().year + if year > current_year: + raise ValueError(f"Year cannot be in the future. Please enter a year up to {current_year}.") + + return year + + @dataclass class Book: """Represent a single book in the collection. @@ -56,6 +87,23 @@ class Book: read: bool = False +@dataclass(frozen=True) +class BookOperationResult: + """Describe the outcome of an operation on a book. + + Attributes: + success (bool): Whether the operation completed successfully. + message (str): A user-friendly summary of the result. + + Examples: + >>> BookOperationResult(success=True, message="Removed the book.") + BookOperationResult(success=True, message='Removed the book.') + """ + + success: bool + message: str + + class BookCollection: """Manage a collection of books stored in a JSON file.""" @@ -75,12 +123,39 @@ def __init__(self) -> None: @contextmanager def _open_data_file(self, mode: str) -> Iterator[IO[str]]: - """Open the collection data file using a shared context manager.""" + """Open the JSON data file using a shared context manager. + + Args: + mode (str): The file mode to pass to ``open()``, such as ``"r"`` or + ``"w"``. + + Yields: + IO[str]: An open text file handle for the collection data file. + + Examples: + >>> collection = BookCollection() + >>> with collection._open_data_file("r") as data_file: + ... hasattr(data_file, "read") + True + """ with open(DATA_FILE, mode) as data_file: yield data_file def _quarantine_corrupted_file(self) -> Path: - """Move unreadable data aside so the app can start with a clean file.""" + """Rename an unreadable data file to a safe backup path. + + Returns: + Path: The new path of the quarantined file. + + Raises: + OSError: If the file cannot be renamed. + + Examples: + >>> collection = BookCollection() + >>> backup_path = Path("data.corrupted.json") + >>> isinstance(backup_path, Path) + True + """ data_path = Path(DATA_FILE) backup_path = data_path.with_name(f"{data_path.stem}.corrupted{data_path.suffix}") counter = 1 @@ -95,7 +170,19 @@ def _quarantine_corrupted_file(self) -> Path: return backup_path def _load_book_data(self) -> List[Book]: - """Validate and convert raw JSON into ``Book`` instances.""" + """Validate and convert raw JSON entries into ``Book`` objects. + + Returns: + List[Book]: The validated books loaded from the JSON file. + + Raises: + ValueError: If the JSON structure or any book entry is invalid. + + Examples: + >>> collection = BookCollection() + >>> isinstance(collection._load_book_data(), list) + True + """ with self._open_data_file("r") as data_file: data = json.load(data_file) @@ -195,10 +282,9 @@ def add_book(self, title: str, author: str, year: int) -> Book: normalized_title = _normalize_required_text(title, "Title") normalized_author = _normalize_required_text(author, "Author") - if year < 0: - raise ValueError("Year cannot be negative.") + validated_year = _validate_publication_year(year) - book = Book(title=normalized_title, author=normalized_author, year=year) + book = Book(title=normalized_title, author=normalized_author, year=validated_year) self.books.append(book) self.save_books() return book @@ -249,8 +335,13 @@ def find_book_by_title(self, title: str) -> Optional[Book]: >>> collection.find_book_by_title("Dune") is None True """ + normalized_title = title.strip() + if not normalized_title: + return None + + normalized_query = normalized_title.casefold() for book in self.books: - if book.title.lower() == title.lower(): + if book.title.casefold() == normalized_query: return book return None @@ -279,30 +370,54 @@ def mark_as_read(self, title: str) -> bool: return True return False - def remove_book(self, title: str) -> bool: + def remove_book(self, title: str) -> BookOperationResult: """Remove the first book whose title matches case-insensitively. Args: title (str): The title of the book to remove. Returns: - bool: ``True`` if a matching book was removed; otherwise, ``False``. + BookOperationResult: Describes whether a book was removed and why. Raises: + ValueError: If ``title`` is empty after trimming. OSError: If the updated collection cannot be written to disk. TypeError: If the updated collection cannot be serialized to JSON. Examples: >>> collection = BookCollection() - >>> collection.remove_book("Dune") + >>> collection.remove_book("Dune").success False """ - book = self.find_book_by_title(title) + normalized_title = _normalize_required_text(title, "Title") + book = self.find_book_by_title(normalized_title) if book: self.books.remove(book) self.save_books() - return True - return False + return BookOperationResult( + success=True, + message=f'Removed "{book.title}" from the collection.', + ) + + partial_matches = [ + candidate.title + for candidate in self.books + if normalized_title.casefold() in candidate.title.casefold() + ] + if partial_matches: + suggestions = ", ".join(f'"{title}"' for title in partial_matches) + return BookOperationResult( + success=False, + message=( + f'No exact match found for "{normalized_title}". ' + f"Try one of these full titles: {suggestions}." + ), + ) + + return BookOperationResult( + success=False, + message=f'Book "{normalized_title}" was not found in the collection.', + ) def find_by_author(self, author: str) -> List[Book]: """Find all books written by a given author. diff --git a/samples/book-app-project/data.json b/samples/book-app-project/data.json new file mode 100644 index 00000000..940c9474 --- /dev/null +++ b/samples/book-app-project/data.json @@ -0,0 +1,38 @@ +[ + { + "title": "The Hobbit", + "author": "J.R.R. Tolkien", + "year": 1937, + "read": false + }, + { + "title": "1984", + "author": "George Orwell", + "year": 1949, + "read": true + }, + { + "title": "Dune", + "author": "Frank Herbert", + "year": 1965, + "read": false + }, + { + "title": "To Kill a Mockingbird", + "author": "Harper Lee", + "year": 1960, + "read": false + }, + { + "title": "Mysterious Book", + "author": "", + "year": 0, + "read": false + }, + { + "title": "Test", + "author": "Someone", + "year": 0, + "read": false + } +] diff --git a/samples/book-app-project/tests/test_book_app.py b/samples/book-app-project/tests/test_book_app.py index bdccc4ba..e85e7c0a 100644 --- a/samples/book-app-project/tests/test_book_app.py +++ b/samples/book-app-project/tests/test_book_app.py @@ -1,5 +1,6 @@ import os import sys +from datetime import date from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -18,27 +19,72 @@ def use_temp_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) +@pytest.fixture +def set_input(monkeypatch: pytest.MonkeyPatch): + """Provide a helper for mocking sequential input responses.""" + + def _set_input(responses: list[str]) -> None: + remaining_responses = responses.copy() + monkeypatch.setattr("builtins.input", lambda _: remaining_responses.pop(0)) + + return _set_input + + class TestHandleAdd: """Tests for handle_add.""" + @pytest.mark.parametrize( + ("responses", "expected_book"), + [ + ([" Dune ", " Frank Herbert ", "1965"], ("Dune", "Frank Herbert", 1965)), + (["Neuromancer", "William Gibson", "0"], ("Neuromancer", "William Gibson", 0)), + ( + ["Snow Crash", "Neal Stephenson", str(date.today().year)], + ("Snow Crash", "Neal Stephenson", date.today().year), + ), + ], + ) + def test_normalizes_text_and_allows_valid_years( + self, + responses: list[str], + expected_book: tuple[str, str, int], + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + set_input(responses) + + result = book_app.handle_add(collection) + + captured = capsys.readouterr() + saved_book = collection.list_books()[0] + assert result == 0 + assert "Book added successfully." in captured.out + assert (saved_book.title, saved_book.author, saved_book.year) == expected_book + @pytest.mark.parametrize( ("responses", "expected_message"), [ (["", "Frank Herbert", "1965"], "Error: Title cannot be empty."), (["Dune", "", "1965"], "Error: Author cannot be empty."), - (["Dune", "Frank Herbert", "invalid"], "Error: invalid literal for int()"), + (["Dune", "Frank Herbert", ""], "Error: Year cannot be empty. Please enter a publication year."), + (["Dune", "Frank Herbert", "invalid"], "Error: Year must be a whole number."), (["Dune", "Frank Herbert", "-1"], "Error: Year cannot be negative."), + ( + ["Dune", "Frank Herbert", str(date.today().year + 1)], + f"Error: Year cannot be in the future. Please enter a year up to {date.today().year}.", + ), ], ) def test_invalid_input( self, responses: list[str], expected_message: str, - monkeypatch: pytest.MonkeyPatch, + set_input, capsys: pytest.CaptureFixture[str], ) -> None: collection = books.BookCollection() - monkeypatch.setattr("builtins.input", lambda _: responses.pop(0)) + set_input(responses) result = book_app.handle_add(collection) @@ -83,13 +129,15 @@ def test_displays_books_with_shared_format( class TestHandleRemove: """Tests for handle_remove.""" + @pytest.mark.parametrize("title", ["", " "]) def test_missing_title( self, - monkeypatch: pytest.MonkeyPatch, + title: str, + set_input, capsys: pytest.CaptureFixture[str], ) -> None: collection = books.BookCollection() - monkeypatch.setattr("builtins.input", lambda _: "") + set_input([title]) result = book_app.handle_remove(collection) @@ -109,19 +157,39 @@ def test_book_not_found( captured = capsys.readouterr() assert result == 1 - assert "Error: Book not found." in captured.out + assert 'Error: Book "Missing Book" was not found in the collection.' in captured.out + + def test_partial_title_shows_suggestion( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + collection.add_book("Dune Messiah", "Frank Herbert", 1969) + monkeypatch.setattr("builtins.input", lambda _: "Dune") + + result = book_app.handle_remove(collection) + + captured = capsys.readouterr() + assert result == 1 + assert ( + 'Error: No exact match found for "Dune". Try one of these full titles: ' + '"Dune Messiah".' + ) in captured.out class TestHandleMarkRead: """Tests for handle_mark_read.""" + @pytest.mark.parametrize("title", ["", " "]) def test_missing_title( self, - monkeypatch: pytest.MonkeyPatch, + title: str, + set_input, capsys: pytest.CaptureFixture[str], ) -> None: collection = books.BookCollection() - monkeypatch.setattr("builtins.input", lambda _: "") + set_input([title]) result = book_app.handle_mark_read(collection) @@ -163,13 +231,15 @@ def test_success( class TestHandleFind: """Tests for handle_find.""" + @pytest.mark.parametrize("author", ["", " "]) def test_missing_author( self, - monkeypatch: pytest.MonkeyPatch, + author: str, + set_input, capsys: pytest.CaptureFixture[str], ) -> None: collection = books.BookCollection() - monkeypatch.setattr("builtins.input", lambda _: "") + set_input([author]) result = book_app.handle_find(collection) @@ -195,10 +265,97 @@ def test_displays_matching_books_with_shared_format( assert "1. [ ] Dune by Frank Herbert (1965)" in captured.out assert "2. [ ] Children of Dune by Frank Herbert (1976)" in captured.out + def test_allows_whitespace_around_author_name( + self, + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + set_input([" Frank Herbert "]) + + result = book_app.handle_find(collection) + + captured = capsys.readouterr() + assert result == 0 + assert "1. [ ] Dune by Frank Herbert (1965)" in captured.out + + +class TestCreateCollectionCommand: + """Tests for create_collection_command.""" + + def test_returns_handler_result(self) -> None: + expected_collection = books.BookCollection() + + def handler(collection: books.BookCollection) -> int: + assert collection is expected_collection + return 7 + + command = book_app.create_collection_command(handler) + + original_collection = book_app.BookCollection + book_app.BookCollection = lambda: expected_collection + try: + result = command() + finally: + book_app.BookCollection = original_collection + + assert result == 7 + + @pytest.mark.parametrize( + ("exception_type", "message"), + [ + (OSError, "cannot open data"), + (ValueError, "invalid book data"), + ], + ) + def test_handles_collection_initialization_errors( + self, + exception_type: type[Exception], + message: str, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + def raise_error() -> None: + raise exception_type(message) + + monkeypatch.setattr(book_app, "BookCollection", raise_error) + command = book_app.create_collection_command(lambda _: 0) + + result = command() + + captured = capsys.readouterr() + assert result == 1 + assert f"Error: {message}" in captured.out + class TestMain: """Tests for main.""" + def test_no_args_shows_help(self, capsys: pytest.CaptureFixture[str]) -> None: + result = book_app.main([]) + + captured = capsys.readouterr() + assert result == 0 + assert "Book Collection Helper" in captured.out + + def test_command_lookup_is_case_insensitive( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls: list[str] = [] + + def fake_help() -> int: + calls.append("help") + return 0 + + monkeypatch.setitem(book_app.COMMAND_HANDLERS, "help", fake_help) + + result = book_app.main(["HeLp"]) + + assert result == 0 + assert calls == ["help"] + def test_help_command_does_not_initialize_collection( self, monkeypatch: pytest.MonkeyPatch, diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index b277cc6a..f2de9502 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -3,6 +3,7 @@ import sys import threading from dataclasses import asdict +from datetime import date from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -360,6 +361,15 @@ def test_add_book_rejects_negative_year(self, empty_collection: BookCollection, with pytest.raises(ValueError, match="Year cannot be negative."): empty_collection.add_book("Dune", "Frank Herbert", year) + def test_add_book_rejects_future_year(self, empty_collection: BookCollection) -> None: + future_year = date.today().year + 1 + + with pytest.raises( + ValueError, + match=rf"Year cannot be in the future\. Please enter a year up to {date.today().year}\.", + ): + empty_collection.add_book("Dune", "Frank Herbert", future_year) + def test_add_book_persists_normalized_values( self, empty_collection: BookCollection, @@ -490,16 +500,27 @@ def test_only_marks_first_matching_title(self, empty_collection: BookCollection) class TestRemoveBook: """Tests for remove_book.""" - def test_removes_book_case_insensitively_and_persists(self, empty_collection: BookCollection) -> None: + def test_removes_book_that_exists_and_persists(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("Dune", "Frank Herbert", 1965) + + result = empty_collection.remove_book("Dune") + reloaded_collection = BookCollection() + + assert result.success is True + assert result.message == 'Removed "Dune" from the collection.' + assert reloaded_collection.books == [] + + def test_matches_title_case_insensitively(self, empty_collection: BookCollection) -> None: empty_collection.add_book("Dune", "Frank Herbert", 1965) result = empty_collection.remove_book("dUnE") reloaded_collection = BookCollection() - assert result is True + assert result.success is True + assert result.message == 'Removed "Dune" from the collection.' assert reloaded_collection.books == [] - def test_returns_false_without_saving_when_title_is_not_found( + def test_returns_feedback_when_book_does_not_exist( self, empty_collection: BookCollection, monkeypatch: pytest.MonkeyPatch, @@ -514,26 +535,49 @@ def fake_save_books() -> None: result = empty_collection.remove_book("Missing Book") - assert result is False + assert result.success is False + assert result.message == 'Book "Missing Book" was not found in the collection.' assert save_books_called is False - def test_returns_false_for_empty_collection(self, empty_collection: BookCollection) -> None: - assert empty_collection.remove_book("Dune") is False + def test_rejects_empty_title(self, empty_collection: BookCollection) -> None: + with pytest.raises(ValueError, match="Title cannot be empty."): + empty_collection.remove_book(" ") + + def test_returns_feedback_when_collection_is_empty(self, empty_collection: BookCollection) -> None: + result = empty_collection.remove_book("Dune") + + assert result.success is False + assert result.message == 'Book "Dune" was not found in the collection.' def test_only_removes_first_matching_title(self, empty_collection: BookCollection) -> None: first_book = empty_collection.add_book("Dune", "Frank Herbert", 1965) second_book = empty_collection.add_book("Dune", "Brian Herbert", 2001) - assert empty_collection.remove_book("Dune") is True + result = empty_collection.remove_book("Dune") + + assert result.success is True assert empty_collection.books == [second_book] assert first_book not in empty_collection.books def test_does_not_remove_book_by_partial_title_match(self, empty_collection: BookCollection) -> None: empty_collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) - assert empty_collection.remove_book("Hob") is False + result = empty_collection.remove_book("Hob") + + assert result.success is False + assert result.message == ( + 'No exact match found for "Hob". Try one of these full titles: "The Hobbit".' + ) assert [book.title for book in empty_collection.books] == ["The Hobbit"] + def test_ignores_whitespace_around_title(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("Dune", "Frank Herbert", 1965) + + result = empty_collection.remove_book(" dune ") + + assert result.success is True + assert empty_collection.books == [] + class TestFindByAuthor: """Tests for find_by_author.""" diff --git a/samples/book-app-project/tests/test_utils.py b/samples/book-app-project/tests/test_utils.py index 9d761be5..58ce5118 100644 --- a/samples/book-app-project/tests/test_utils.py +++ b/samples/book-app-project/tests/test_utils.py @@ -1,5 +1,6 @@ import os import sys +from datetime import date sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -103,29 +104,33 @@ def test_retries_after_empty_title( assert "Title cannot be empty. Please enter a book title." in captured.out @pytest.mark.parametrize( - ("year_input", "expected_title", "expected_author"), + ("invalid_year", "expected_message"), [ - ("invalid", "Dune", "Frank Herbert"), - ("19.65", "Dune", "Frank Herbert"), - ("1965a", "Dune", "Frank Herbert"), - ("", "Dune", "Frank Herbert"), + ("invalid", "Year must be a whole number."), + ("19.65", "Year must be a whole number."), + ("1965a", "Year must be a whole number."), + ("", "Year cannot be empty. Please enter a publication year."), + ("-1", "Year cannot be negative."), + ( + str(date.today().year + 1), + f"Year cannot be in the future. Please enter a year up to {date.today().year}.", + ), ], ) - def test_defaults_invalid_year_formats_to_zero( + def test_reprompts_after_invalid_year_input( self, mock_input, capsys: pytest.CaptureFixture[str], - year_input: str, - expected_title: str, - expected_author: str, + invalid_year: str, + expected_message: str, ) -> None: - mock_input([expected_title, expected_author, year_input]) + mock_input(["Dune", "Frank Herbert", invalid_year, "1965"]) result = utils.get_book_details() captured = capsys.readouterr() - assert result == (expected_title, expected_author, 0) - assert "Invalid year. Defaulting to 0." in captured.out + assert result == ("Dune", "Frank Herbert", 1965) + assert expected_message in captured.out def test_returns_details_for_valid_input(self, mock_input) -> None: mock_input(["The Hobbit", "J.R.R. Tolkien", "1937"]) @@ -196,13 +201,23 @@ class TestParsePublicationYear: ("year_input", "expected_result"), [ ("1965", (1965, None)), - ("invalid", (0, "Invalid year. Defaulting to 0.")), + (" 1965 ", (1965, None)), + ("", (None, "Year cannot be empty. Please enter a publication year.")), + ("invalid", (None, "Year must be a whole number.")), + ("-1", (None, "Year cannot be negative.")), + ( + str(date.today().year + 1), + ( + None, + f"Year cannot be in the future. Please enter a year up to {date.today().year}.", + ), + ), ], ) def test_returns_parsed_year_and_optional_message( self, year_input: str, - expected_result: tuple[int, str | None], + expected_result: tuple[int | None, str | None], ) -> None: assert utils.parse_publication_year(year_input) == expected_result diff --git a/samples/book-app-project/utils.py b/samples/book-app-project/utils.py index 5deff73f..512f7d32 100644 --- a/samples/book-app-project/utils.py +++ b/samples/book-app-project/utils.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from datetime import date from typing import Final, Literal, TypeAlias, cast from books import Book @@ -84,11 +85,28 @@ def validate_title(title: str) -> str | None: return "Title cannot be empty. Please enter a book title." -def parse_publication_year(year_input: str) -> tuple[int, str | None]: +def current_calendar_year() -> int: + return date.today().year + + +def parse_publication_year(year_input: str) -> tuple[int | None, str | None]: + normalized_year = year_input.strip() + if not normalized_year: + return None, "Year cannot be empty. Please enter a publication year." + try: - return int(year_input), None + year = int(normalized_year) except ValueError: - return 0, "Invalid year. Defaulting to 0." + return None, "Year must be a whole number." + + if year < 0: + return None, "Year cannot be negative." + + max_year = current_calendar_year() + if year > max_year: + return None, f"Year cannot be in the future. Please enter a year up to {max_year}." + + return year, None def get_book_details() -> BookDetails: @@ -101,9 +119,7 @@ def get_book_details() -> BookDetails: tuple[str, str, int]: A tuple containing: - title: The non-empty book title entered by the user. - author: The author name entered by the user. - - year: The publication year as an integer. If the entered year - cannot be converted to an integer, the function prints a message - and returns 0 for the year instead. + - year: The publication year as an integer after validation. """ while True: title = input("Enter book title: ").strip() @@ -115,9 +131,12 @@ def get_book_details() -> BookDetails: author = input("Enter author: ").strip() - year_input = input("Enter publication year: ").strip() - year, error_message = parse_publication_year(year_input) - if error_message is not None: + while True: + year_input = input("Enter publication year: ").strip() + year, error_message = parse_publication_year(year_input) + if error_message is None and year is not None: + break + print(error_message) return title, author, year diff --git a/test-mcp.file b/test-mcp.file new file mode 100644 index 00000000..e69de29b