diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 84fa9f45187..6342034d09a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,15 +1,18 @@ * @cli/code-reviewers -pkg/cmd/codespace/ @cli/codespaces -internal/codespaces/ @cli/codespaces +pkg/cmd/codespace/ @cli/codespaces @cli/code-reviewers +internal/codespaces/ @cli/codespaces @cli/code-reviewers # Limit Package Security team ownership to the attestation command package and related integration tests -pkg/cmd/attestation/ @cli/package-security -pkg/cmd/release/attestation/ @cli/package-security -pkg/cmd/release/verify/ @cli/package-security -pkg/cmd/release/verify-asset/ @cli/package-security -pkg/cmd/release/shared/ @cli/package-security +pkg/cmd/attestation/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/attestation/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/verify/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/verify-asset/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/shared/ @cli/package-security @cli/code-reviewers -test/integration/attestation-cmd @cli/package-security +test/integration/attestation-cmd @cli/package-security @cli/code-reviewers -pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers +pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers @cli/code-reviewers + +pkg/cmd/skills/ @cli/skills @cli/code-reviewers +internal/skills/ @cli/skills @cli/code-reviewers diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4c08abeabef..3d974364d2e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 3 ignore: - dependency-name: "*" update-types: @@ -12,3 +14,5 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 3 diff --git a/.github/extensions/terminal-mockup/README.md b/.github/extensions/terminal-mockup/README.md new file mode 100644 index 00000000000..79c04bbafaf --- /dev/null +++ b/.github/extensions/terminal-mockup/README.md @@ -0,0 +1,51 @@ +# Terminal mockup canvas + +A [GitHub Copilot app](https://github.com/github/app) canvas extension +that renders mock-up `gh` output as VSCode-styled terminal screenshots. Built +for producing marketing imagery (blog posts, changelogs, social) where real +terminal recordings are impractical. + +## Using it + +Open the canvas from a Copilot app session. Pick a starting mockup from the +library dropdown, edit the content and toolbar options, and export a PNG via +the download button. Files download through the browser/runtime, which +typically lands them in the configured downloads directory. + +The toolbar controls font, font size, width, window chrome (macOS or none), +backdrop (subtle blue glow / grid / none), and an "auto-style" toggle that +colorizes common `gh` patterns without requiring inline tags. + +## Content markup + +Content can be authored as raw ANSI escapes, or with a more readable bracket +syntax that the renderer maps to the VSCode Dark+ palette: + +- Named colors: `[red]`, `[green]`, `[yellow]`, `[blue]`, `[magenta]`, + `[cyan]`, `[white]`, `[black]` (bright variants prefixed `br`, e.g. + `[brblue]`), plus `[muted]` for grayed-out text and `[link]` for blue + underlined link styling. +- Modifiers: `[bold]` (or `[b]`), `[italic]` (or `[i]`), `[underline]` + (or `[u]`), `[dim]`. +- Each tag closes with its matching `[/name]`, e.g. `[red]error[/red]`. + +When auto-style is on, the renderer also colorizes PR/issue states, labels, +checkboxes, timestamps, and similar conventional output without explicit tags. + +## Library + +Mockups live in two locations: + +- **Project library** at `./library/*.json`: committed to the repo, the + shared starting set. +- **User library** at `$COPILOT_HOME/extensions/terminal-mockup/artifacts/*.json`: + local-only, for personal experiments. + +Saving a new mockup writes to the user library by default; renaming an +existing one preserves its scope. The dropdown shows both, prefixed by scope. + +## Vendored dependencies + +[`assets/html2canvas.min.js`](./assets/html2canvas.min.js) is the unmodified +[html2canvas](https://github.com/niklasvh/html2canvas) 1.4.1 distribution +(MIT). Used to rasterize the rendered DOM into a PNG in-browser. diff --git a/.github/extensions/terminal-mockup/assets/ansi.js b/.github/extensions/terminal-mockup/assets/ansi.js new file mode 100644 index 00000000000..2942956be0e --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/ansi.js @@ -0,0 +1,258 @@ +// ANSI SGR + bracket markup tokenizer. +// Produces a flat array of styled segments: { text, classes }. +// +// Supports: +// - ANSI CSI SGR sequences: \x1b[m (0, 1, 3, 4, 22, 23, 24, 30-37, 39, 90-97, 38;5;N, 38;2;R;G;B) +// - Bracket markup: [b]..[/b], [i]..[/i], [u]..[/u], [dim]..[/dim], [muted]..[/muted], [link]..[/link], +// [red] [green] [yellow] [blue] [magenta] [cyan] [white] [black] +// [brred] [brgreen] [bryellow] [brblue] [brmagenta] [brcyan] [brwhite] [brblack] +// - Plain text passthrough +// +// Bracket tags can nest. ANSI state machine handles standard SGR codes only; +// other CSI/OSC sequences are dropped silently. + +const ANSI_FG = { + 30: "black", 31: "red", 32: "green", 33: "yellow", + 34: "blue", 35: "magenta", 36: "cyan", 37: "white", + 90: "br-black", 91: "br-red", 92: "br-green", 93: "br-yellow", + 94: "br-blue", 95: "br-magenta", 96: "br-cyan", 97: "br-white", +}; + +const COLOR_NAMES = new Set([ + "red", "green", "yellow", "blue", "magenta", "cyan", "white", "black", + "brred", "brgreen", "bryellow", "brblue", "brmagenta", "brcyan", "brwhite", "brblack", +]); +const TAG_TO_FG = { + red: "red", green: "green", yellow: "yellow", blue: "blue", + magenta: "magenta", cyan: "cyan", white: "white", black: "black", + brred: "br-red", brgreen: "br-green", bryellow: "br-yellow", brblue: "br-blue", + brmagenta: "br-magenta", brcyan: "br-cyan", brwhite: "br-white", brblack: "br-black", +}; + +function classesFromState(state) { + const cls = []; + if (state.fg) cls.push(`fg-${state.fg}`); + if (state.bold) cls.push("bold"); + if (state.italic) cls.push("italic"); + if (state.underline) cls.push("underline"); + if (state.dim) cls.push("dim"); + return cls; +} + +function emit(out, text, state) { + if (!text) return; + out.push({ text, classes: classesFromState(state) }); +} + +// Step 1: parse ANSI escape codes into a flat segment list, ignoring brackets. +function parseAnsi(input) { + const segments = []; + const state = { fg: null, bold: false, italic: false, underline: false, dim: false }; + let buf = ""; + let i = 0; + while (i < input.length) { + const ch = input.charCodeAt(i); + if (ch === 0x1b && input[i + 1] === "[") { + if (buf) { emit(segments, buf, state); buf = ""; } + // Find terminator + let j = i + 2; + while (j < input.length) { + const c = input.charCodeAt(j); + // CSI parameter bytes: 0x30-0x3f; intermediates: 0x20-0x2f; final: 0x40-0x7e + if (c >= 0x40 && c <= 0x7e) break; + j++; + } + const final = input[j]; + const params = input.slice(i + 2, j); + if (final === "m") applySgr(state, params); + i = j + 1; + continue; + } + buf += input[i]; + i++; + } + if (buf) emit(segments, buf, state); + return segments; +} + +function applySgr(state, paramsStr) { + const tokens = paramsStr.split(";").map((t) => (t === "" ? 0 : Number(t))); + let i = 0; + while (i < tokens.length) { + const t = tokens[i]; + if (t === 0) { + state.fg = null; state.bold = false; state.italic = false; + state.underline = false; state.dim = false; + } else if (t === 1) state.bold = true; + else if (t === 2) state.dim = true; + else if (t === 3) state.italic = true; + else if (t === 4) state.underline = true; + else if (t === 22) { state.bold = false; state.dim = false; } + else if (t === 23) state.italic = false; + else if (t === 24) state.underline = false; + else if (t === 39) state.fg = null; + else if (ANSI_FG[t]) state.fg = ANSI_FG[t]; + else if (t === 38) { + const mode = tokens[i + 1]; + if (mode === 5) { + state.fg = map256(tokens[i + 2]); + i += 2; + } else if (mode === 2) { + // Truecolor not mapped to a named slot; skip params and leave fg unchanged. + i += 4; + } + } + // ignore 40-49, 48 etc (we don't render backgrounds for now) + i++; + } +} + +// Map 256-color cube to nearest named slot. Coarse but adequate. +function map256(n) { + if (n == null) return null; + if (n < 8) return ANSI_FG[30 + n] || null; + if (n < 16) return ANSI_FG[90 + (n - 8)] || null; + // Grayscale ramp (232 = near-black, 255 = near-white). The middle range + // is the "muted" gray that gh uses for footer URLs, bullet separators, etc. + if (n >= 232 && n <= 243) return "muted"; + if (n >= 244 && n <= 250) return "br-black"; // softer gray + // Color cube fallback: no good mapping, let the default fg apply. + return null; +} +// Step 2: walk segments and split on bracket markup, updating per-segment classes. +function parseBrackets(segments) { + const out = []; + const stack = []; // each entry: array of class strings added by this tag + const tagRe = /\[(\/?)([a-zA-Z]+)\]/g; + for (const seg of segments) { + const text = seg.text; + let last = 0; + tagRe.lastIndex = 0; + let m; + const baseClasses = seg.classes.slice(); + while ((m = tagRe.exec(text)) !== null) { + const before = text.slice(last, m.index); + if (before) out.push({ text: before, classes: mergeClasses(baseClasses, stack) }); + const closing = m[1] === "/"; + const tag = m[2].toLowerCase(); + const added = tagToClasses(tag); + if (added.length === 0) { + // Not a recognized tag; treat as literal text. + out.push({ text: m[0], classes: mergeClasses(baseClasses, stack) }); + } else if (closing) { + // Pop most recent matching frame. + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].tag === tag) { stack.splice(i, 1); break; } + } + } else { + stack.push({ tag, classes: added }); + } + last = m.index + m[0].length; + } + const tail = text.slice(last); + if (tail) out.push({ text: tail, classes: mergeClasses(baseClasses, stack) }); + } + return out; +} + +function tagToClasses(tag) { + if (tag === "b" || tag === "bold") return ["bold"]; + if (tag === "i" || tag === "italic") return ["italic"]; + if (tag === "u" || tag === "underline") return ["underline"]; + if (tag === "dim") return ["dim"]; + if (tag === "muted") return ["fg-muted"]; + if (tag === "link") return ["fg-br-blue", "underline"]; + if (COLOR_NAMES.has(tag)) return [`fg-${TAG_TO_FG[tag]}`]; + return []; +} + +function mergeClasses(base, stack) { + const set = new Set(base); + for (const frame of stack) { + for (const c of frame.classes) set.add(c); + } + return Array.from(set); +} + +// Step 3: optional auto-styling for plain-looking segments. +// Operates only on segments that have no styling yet, to avoid clobbering +// user-specified colors. Splits on detected patterns and inserts styled spans. +function autoStyle(segments) { + const out = []; + for (const seg of segments) { + if (seg.classes.length > 0) { + out.push(seg); + continue; + } + autoStyleSegment(seg.text, out); + } + return out; +} + +function autoStyleSegment(text, out) { + // Process line by line so we can detect $ prompts. + const lines = text.split(/(\n)/); + for (const line of lines) { + if (line === "\n") { + out.push({ text: "\n", classes: [] }); + continue; + } + if (line === "") continue; + // Prompt line: leading `$ ` + const promptMatch = line.match(/^(\s*)(\$)( )(.*)$/); + if (promptMatch) { + const [, leading, dollar, space, rest] = promptMatch; + if (leading) out.push({ text: leading, classes: [] }); + out.push({ text: dollar, classes: ["fg-muted"] }); + out.push({ text: space, classes: [] }); + // Apply inline auto-stylers to the rest of the prompt line + autoStyleInline(rest, out); + continue; + } + autoStyleInline(line, out); + } +} + +function autoStyleInline(text, out) { + // Detect URLs and color/dim them; detect standalone +N/-N tokens for diff stats; detect #NNN refs. + // Single regex with alternation; iterate over matches. + const re = /(https?:\/\/[^\s)>\]]+)|(? last) out.push({ text: text.slice(last, m.index), classes: [] }); + if (m[1]) { + out.push({ text: m[1], classes: ["fg-muted"] }); + } else if (m[2]) { + const cls = m[2].startsWith("+") ? "fg-br-green" : "fg-br-red"; + out.push({ text: m[2], classes: [cls] }); + } else if (m[3]) { + out.push({ text: m[3], classes: ["fg-br-blue"] }); + } + last = m.index + m[0].length; + } + if (last < text.length) out.push({ text: text.slice(last), classes: [] }); +} + +export function parse(input, { autoStyle: enableAuto = true } = {}) { + const ansiSegments = parseAnsi(input ?? ""); + const bracketSegments = parseBrackets(ansiSegments); + return enableAuto ? autoStyle(bracketSegments) : bracketSegments; +} + +export function renderToDom(target, input, opts) { + const segments = parse(input, opts); + target.replaceChildren(); + const frag = document.createDocumentFragment(); + for (const seg of segments) { + if (seg.classes.length === 0) { + frag.appendChild(document.createTextNode(seg.text)); + } else { + const span = document.createElement("span"); + span.className = seg.classes.join(" "); + span.textContent = seg.text; + frag.appendChild(span); + } + } + target.appendChild(frag); +} diff --git a/.github/extensions/terminal-mockup/assets/app.js b/.github/extensions/terminal-mockup/assets/app.js new file mode 100644 index 00000000000..91521c549ae --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/app.js @@ -0,0 +1,634 @@ +// App glue: wires editor + toolbar to the renderer, listens for state pushes +// from the extension over SSE, and handles PNG export via html2canvas. + +import { renderToDom } from "./ansi.js"; + +const $ = (sel) => document.querySelector(sel); + +const editor = $("#editor"); +const terminal = $("#terminal"); +const windowEl = $("#window"); +const mockup = $("#mockup"); +const fontSel = $("#ctl-font"); +const fontSize = $("#ctl-fontsize"); +const fontSizeOut = $("#ctl-fontsize-out"); +const widthIn = $("#ctl-width"); +const widthOut = $("#ctl-width-out"); +const chromeSel = $("#ctl-chrome"); +const backdropSel = $("#ctl-backdrop"); +const bodyGradCb = $("#ctl-bodygrad"); +const autoStyleCb = $("#ctl-autostyle"); +const downloadBtn = $("#btn-download"); +const savedSel = $("#ctl-saved"); +const saveAsBtn = $("#btn-save"); +const saveAsProjectBtn = $("#btn-save-project"); +const saveOverwriteBtn = $("#btn-save-overwrite"); +const deleteBtn = $("#btn-delete"); +const toast = $("#toast"); +const formatSel = $("#ctl-format"); + +let state = { + content: "", + options: { + font: "menlo", + fontSize: 14, + width: 800, + chrome: "none", + backdrop: "none", + bodyGradient: false, + autoStyle: true, + }, +}; + +function applyState() { + editor.value = state.content; + fontSel.value = state.options.font; + fontSize.value = String(state.options.fontSize); + fontSizeOut.textContent = `${state.options.fontSize}px`; + widthIn.value = String(state.options.width); + widthOut.textContent = `${state.options.width}px`; + chromeSel.value = state.options.chrome; + backdropSel.value = state.options.backdrop; + bodyGradCb.checked = !!state.options.bodyGradient; + autoStyleCb.checked = !!state.options.autoStyle; + rerender(); +} + +function rerender() { + // Apply visual options + windowEl.dataset.font = state.options.font; + windowEl.classList.toggle("has-chrome", state.options.chrome === "macos"); + windowEl.classList.toggle("no-chrome", state.options.chrome === "none"); + windowEl.classList.toggle("body-gradient", !!state.options.bodyGradient); + windowEl.style.setProperty("--mockup-width", `${state.options.width}px`); + terminal.style.setProperty("--term-fontsize", `${state.options.fontSize}px`); + + mockup.classList.remove("backdrop-grid", "backdrop-solid", "backdrop-none"); + mockup.classList.add(`backdrop-${state.options.backdrop}`); + + renderToDom(terminal, state.content, { autoStyle: state.options.autoStyle }); +} + +// Initial load: pull server-side state set via canvas open input or set_content action. +async function init() { + try { + const res = await fetch("/state", { cache: "no-store" }); + if (res.ok) { + const remote = await res.json(); + if (remote && typeof remote.content === "string" && remote.content.trim().length > 0) { + state.content = remote.content; + } + if (remote && remote.options && typeof remote.options === "object") { + state.options = { ...state.options, ...remote.options }; + } + } + } catch { + // ignore; fall back to defaults + } + applyState(); + connectSse(); +} + +function connectSse() { + let es; + const open = () => { + es = new EventSource("/events"); + es.onmessage = (evt) => { + try { + const data = JSON.parse(evt.data); + if (data && data.type === "library_changed") { + if (data.action === "saved" && typeof data.slug === "string" && (data.scope === "project" || data.scope === "user")) { + loadedSlug = data.slug; + loadedScope = data.scope; + loadedName = data.name || data.slug; + } else if (data.action === "deleted" && typeof data.slug === "string" && data.slug === loadedSlug && data.scope === loadedScope) { + loadedSlug = null; + loadedScope = null; + loadedName = null; + } + refreshLibrary().then(() => updateLoadedAffordances()); + return; + } + if (data && data.type === "batch_export") { + runBatchExport(data).catch((err) => showToast(`Batch export failed: ${err.message}`)); + return; + } + let changed = false; + if (typeof data.content === "string" && data.content !== state.content) { + state.content = data.content; + changed = true; + } + if (data.options && typeof data.options === "object") { + const next = { ...state.options, ...data.options }; + if (JSON.stringify(next) !== JSON.stringify(state.options)) { + state.options = next; + changed = true; + } + } + if (changed) applyState(); + } catch {} + }; + es.onerror = () => { + es.close(); + setTimeout(open, 1500); + }; + }; + open(); +} + +// Event wiring +editor.addEventListener("input", () => { + state.content = editor.value; + rerender(); +}); + +fontSel.addEventListener("change", () => { + state.options.font = fontSel.value; + rerender(); +}); + +fontSize.addEventListener("input", () => { + state.options.fontSize = Number(fontSize.value); + fontSizeOut.textContent = `${fontSize.value}px`; + rerender(); +}); + +widthIn.addEventListener("input", () => { + state.options.width = Number(widthIn.value); + widthOut.textContent = `${widthIn.value}px`; + rerender(); +}); + +chromeSel.addEventListener("change", () => { + state.options.chrome = chromeSel.value; + rerender(); +}); + +backdropSel.addEventListener("change", () => { + state.options.backdrop = backdropSel.value; + rerender(); +}); + +bodyGradCb.addEventListener("change", () => { + state.options.bodyGradient = bodyGradCb.checked; + rerender(); +}); + +autoStyleCb.addEventListener("change", () => { + state.options.autoStyle = autoStyleCb.checked; + rerender(); +}); + +// Saved-mockups library. Two scopes: +// project: .github/extensions/terminal-mockup/library/ (committed, shared) +// user: ~/.copilot/extensions/terminal-mockup/artifacts/ (per-user) +let loadedSlug = null; +let loadedScope = null; +let loadedName = null; + +function scopedId(scope, slug) { return `${scope}:${slug}`; } +function parseScopedId(value) { + if (!value) return null; + const i = value.indexOf(":"); + if (i < 1) return null; + const scope = value.slice(0, i); + const slug = value.slice(i + 1); + if (scope !== "project" && scope !== "user") return null; + if (!slug) return null; + return { scope, slug }; +} +function scopeLabel(scope) { return scope === "project" ? "Project" : "Local"; } + +function slugify(name) { + return String(name || "") + .toLowerCase() + .normalize("NFKD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .slice(0, 80); +} + +function updateLoadedAffordances() { + const has = !!loadedSlug; + saveOverwriteBtn.disabled = !has; + deleteBtn.disabled = !has; + if (has) { + const label = loadedName || loadedSlug; + const scopeTag = loadedScope === "project" ? " (Project)" : " (Local)"; + saveOverwriteBtn.textContent = `Save "${label}"${scopeTag}`; + deleteBtn.textContent = loadedScope === "project" ? "Delete from project" : "Delete"; + } else { + saveOverwriteBtn.textContent = "Save"; + deleteBtn.textContent = "Delete"; + } +} + +async function refreshLibrary() { + try { + const res = await fetch("/mockups", { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + const items = Array.isArray(data.items) ? data.items : []; + savedSel.innerHTML = ''; + const groups = { project: [], user: [] }; + for (const it of items) { + if (it && (it.scope === "project" || it.scope === "user")) groups[it.scope].push(it); + } + for (const scope of ["project", "user"]) { + if (groups[scope].length === 0) continue; + const og = document.createElement("optgroup"); + og.label = scopeLabel(scope); + for (const it of groups[scope]) { + const opt = document.createElement("option"); + opt.value = scopedId(scope, it.slug); + opt.textContent = it.name || it.slug; + og.appendChild(opt); + } + savedSel.appendChild(og); + } + if (loadedSlug && loadedScope && items.some((i) => i.scope === loadedScope && i.slug === loadedSlug)) { + savedSel.value = scopedId(loadedScope, loadedSlug); + } + } catch (e) { + // ignore; library just stays empty + } +} + +async function loadMockup(scope, slug) { + if (!slug || !scope) { + loadedSlug = null; + loadedScope = null; + loadedName = null; + updateLoadedAffordances(); + return; + } + try { + const res = await fetch(`/mockups/${encodeURIComponent(scope)}/${encodeURIComponent(slug)}`, { cache: "no-store" }); + if (!res.ok) throw new Error(`load failed: ${res.status}`); + const doc = await res.json(); + state.content = typeof doc.content === "string" ? doc.content : ""; + state.options = { ...state.options, ...(doc.options || {}) }; + loadedSlug = slug; + loadedScope = scope; + loadedName = doc.name || slug; + applyState(); + updateLoadedAffordances(); + showToast(`Loaded "${loadedName}" (${scopeLabel(scope)})`); + } catch (e) { + showToast(`Load failed: ${e.message}`); + } +} + +async function saveMockup(scope, name, slug) { + const body = { + name: name || slug, + content: state.content, + options: state.options, + }; + const url = slug + ? `/mockups/${encodeURIComponent(scope)}/${encodeURIComponent(slug)}` + : `/mockups/${encodeURIComponent(scope)}`; + if (!slug) body.name = name; + const res = await fetch(url, { + method: slug ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `save failed: ${res.status}`); + } + return await res.json(); +} + +savedSel.addEventListener("change", () => { + const parsed = parseScopedId(savedSel.value); + if (parsed) loadMockup(parsed.scope, parsed.slug); + else { + loadedSlug = null; + loadedScope = null; + loadedName = null; + updateLoadedAffordances(); + } +}); + +function promptSaveName(defaultValue, scope) { + return new Promise((resolve) => { + const dialog = document.getElementById("save-dialog"); + const input = document.getElementById("save-name"); + const cancel = document.getElementById("save-cancel"); + const form = document.getElementById("save-form"); + const heading = document.getElementById("save-heading"); + if (!dialog || typeof dialog.showModal !== "function") { + const v = window.prompt(`Save mockup to ${scopeLabel(scope)} library as:`, defaultValue || ""); + resolve(v && v.trim() ? v.trim() : null); + return; + } + if (heading) heading.textContent = scope === "project" ? "Save to project library" : "Save to local library"; + input.value = defaultValue || ""; + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + form.removeEventListener("submit", onSubmit); + cancel.removeEventListener("click", onCancel); + dialog.removeEventListener("close", onClose); + resolve(value); + }; + const onSubmit = (e) => { + e.preventDefault(); + const value = (input.value || "").trim(); + settle(value || null); + dialog.close(value ? "ok" : ""); + }; + const onCancel = () => { + settle(null); + dialog.close(""); + }; + const onClose = () => settle(null); + form.addEventListener("submit", onSubmit); + cancel.addEventListener("click", onCancel); + dialog.addEventListener("close", onClose); + dialog.showModal(); + setTimeout(() => input.focus(), 0); + input.select(); + }); +} + +async function saveAs(scope, button) { + const name = await promptSaveName(loadedName || "", scope); + if (!name) return; + const slug = slugify(name); + if (!slug) { + showToast("Name needs at least one alphanumeric character"); + return; + } + button.disabled = true; + try { + const result = await saveMockup(scope, name, slug); + loadedScope = result.scope || scope; + loadedSlug = result.slug; + loadedName = result.doc?.name || name; + await refreshLibrary(); + savedSel.value = scopedId(loadedScope, loadedSlug); + updateLoadedAffordances(); + showToast(`Saved "${loadedName}" to ${scopeLabel(loadedScope)} library`); + } catch (e) { + showToast(`Save failed: ${e.message}`); + } finally { + button.disabled = false; + } +} + +saveAsBtn.addEventListener("click", () => saveAs("user", saveAsBtn)); +if (saveAsProjectBtn) { + saveAsProjectBtn.addEventListener("click", () => saveAs("project", saveAsProjectBtn)); +} + +saveOverwriteBtn.addEventListener("click", async () => { + if (!loadedSlug || !loadedScope) return; + saveOverwriteBtn.disabled = true; + try { + await saveMockup(loadedScope, loadedName || loadedSlug, loadedSlug); + showToast(`Saved "${loadedName || loadedSlug}" to ${scopeLabel(loadedScope)}`); + } catch (e) { + showToast(`Save failed: ${e.message}`); + } finally { + updateLoadedAffordances(); + } +}); + +deleteBtn.addEventListener("click", async () => { + if (!loadedSlug || !loadedScope) return; + const scopeMsg = loadedScope === "project" ? " from the project library (will show as a deleted file in git)" : ""; + if (!confirm(`Delete "${loadedName || loadedSlug}"${scopeMsg}?`)) return; + try { + const res = await fetch(`/mockups/${encodeURIComponent(loadedScope)}/${encodeURIComponent(loadedSlug)}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`delete failed: ${res.status}`); + showToast(`Deleted "${loadedName || loadedSlug}"`); + loadedSlug = null; + loadedScope = null; + loadedName = null; + await refreshLibrary(); + updateLoadedAffordances(); + } catch (e) { + showToast(`Delete failed: ${e.message}`); + } +}); + +// Refresh library on init +refreshLibrary(); + +// Export +function currentFormat() { + const v = (formatSel && formatSel.value) || "png"; + if (v === "jpg" || v === "jpeg") { + return { ext: "jpg", mime: "image/jpeg", quality: 0.92, label: "JPG", background: "#04060c" }; + } + return { ext: "png", mime: "image/png", quality: undefined, label: "PNG", background: null }; +} + +async function renderToCanvas(background) { + // Wait one tick so fonts settle if user just changed them + await document.fonts.ready; + const canvas = await html2canvas(mockup, { + backgroundColor: background ?? null, + scale: 3, + useCORS: true, + logging: false, + }); + return canvas; +} + +function updateExportLabels() { + const fmt = currentFormat(); + downloadBtn.textContent = `Download ${fmt.label}`; +} +if (formatSel) { + formatSel.addEventListener("change", updateExportLabels); + updateExportLabels(); +} + +function showToast(msg) { + toast.textContent = msg; + toast.hidden = false; + clearTimeout(showToast._t); + showToast._t = setTimeout(() => { toast.hidden = true; }, 2200); +} + +downloadBtn.addEventListener("click", async () => { + downloadBtn.disabled = true; + try { + const fmt = currentFormat(); + const canvas = await renderToCanvas(fmt.background); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, fmt.mime, fmt.quality)); + if (!blob) throw new Error(`Could not encode ${fmt.label}`); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${loadedSlug || "gh-terminal-mockup"}.${fmt.ext}`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + showToast(`Saved ${fmt.label}`); + } catch (e) { + showToast(`Export failed: ${e.message}`); + } finally { + downloadBtn.disabled = false; + } +}); + +async function runBatchExport({ slugs, suffix, format }) { + if (!Array.isArray(slugs) || slugs.length === 0) return; + const fmtOverride = format === "jpg" ? { ext: "jpg", mime: "image/jpeg", quality: 0.92, label: "JPG", background: "#04060c" } + : format === "png" ? { ext: "png", mime: "image/png", quality: undefined, label: "PNG", background: null } + : null; + const savedContent = state.content; + const savedSlugRef = loadedSlug; + const savedScopeRef = loadedScope; + const savedNameRef = loadedName; + downloadBtn.disabled = true; + try { + for (const entry of slugs) { + const parsed = parseScopedId(entry); + const slug = parsed ? parsed.slug : entry; + const url = parsed + ? `/mockups/${encodeURIComponent(parsed.scope)}/${encodeURIComponent(parsed.slug)}` + : `/mockups/${encodeURIComponent(slug)}`; + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + showToast(`Skipping "${slug}": ${res.status}`); + continue; + } + const doc = await res.json(); + state.content = typeof doc.content === "string" ? doc.content : ""; + applyState(); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const fmt = fmtOverride || currentFormat(); + const canvas = await renderToCanvas(fmt.background); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, fmt.mime, fmt.quality)); + if (!blob) throw new Error(`Could not encode ${fmt.label}`); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = `${slug}${suffix || ""}.${fmt.ext}`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1500); + showToast(`Saved ${a.download}`); + await new Promise((r) => setTimeout(r, 800)); + } catch (e) { + showToast(`Export of "${slug}" failed: ${e.message}`); + } + } + } finally { + state.content = savedContent; + loadedSlug = savedSlugRef; + loadedScope = savedScopeRef; + loadedName = savedNameRef; + applyState(); + downloadBtn.disabled = false; + } +} + +// Resizable editor pane +const STORAGE_KEY = "terminal-mockup.editorHeight"; +const MIN_EDITOR = 80; +const MIN_PREVIEW = 160; +const appRoot = document.querySelector(".app"); +const resizeHandle = document.getElementById("resize-handle"); + +function clampHeight(h) { + const available = window.innerHeight - document.querySelector(".toolbar").offsetHeight - 6; + const max = Math.max(MIN_EDITOR, available - MIN_PREVIEW); + return Math.max(MIN_EDITOR, Math.min(max, h)); +} +function setEditorHeight(h) { + const clamped = clampHeight(h); + appRoot.style.setProperty("--editor-height", `${clamped}px`); + return clamped; +} +const saved = Number(localStorage.getItem(STORAGE_KEY)); +if (Number.isFinite(saved) && saved > 0) setEditorHeight(saved); + +let dragStartY = 0; +let dragStartHeight = 0; +function onPointerMove(e) { + const dy = e.clientY - dragStartY; + setEditorHeight(dragStartHeight - dy); +} +function onPointerUp(e) { + resizeHandle.classList.remove("dragging"); + resizeHandle.releasePointerCapture?.(e.pointerId); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + const cur = parseInt(getComputedStyle(appRoot).getPropertyValue("--editor-height"), 10); + if (Number.isFinite(cur)) localStorage.setItem(STORAGE_KEY, String(cur)); +} +resizeHandle.addEventListener("pointerdown", (e) => { + e.preventDefault(); + dragStartY = e.clientY; + const cs = getComputedStyle(appRoot).getPropertyValue("--editor-height"); + dragStartHeight = parseInt(cs, 10) || 240; + resizeHandle.classList.add("dragging"); + resizeHandle.setPointerCapture?.(e.pointerId); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); +}); +resizeHandle.addEventListener("dblclick", () => { + setEditorHeight(240); + localStorage.setItem(STORAGE_KEY, "240"); +}); +resizeHandle.addEventListener("keydown", (e) => { + const cs = getComputedStyle(appRoot).getPropertyValue("--editor-height"); + const cur = parseInt(cs, 10) || 240; + const step = e.shiftKey ? 40 : 12; + if (e.key === "ArrowUp") { setEditorHeight(cur + step); e.preventDefault(); } + else if (e.key === "ArrowDown") { setEditorHeight(cur - step); e.preventDefault(); } + else return; + const next = parseInt(getComputedStyle(appRoot).getPropertyValue("--editor-height"), 10); + if (Number.isFinite(next)) localStorage.setItem(STORAGE_KEY, String(next)); +}); +window.addEventListener("resize", () => { + const cs = getComputedStyle(appRoot).getPropertyValue("--editor-height"); + const cur = parseInt(cs, 10) || 240; + setEditorHeight(cur); +}); + +// Pane visibility toggles +const TOOLBAR_KEY = "terminal-mockup.toolbarCollapsed"; +const EDITOR_COLLAPSED_KEY = "terminal-mockup.editorCollapsed"; +const toggleToolbarBtn = document.getElementById("toggle-toolbar"); +const toggleEditorBtn = document.getElementById("toggle-editor"); + +function applyToolbarCollapsed(collapsed) { + appRoot.classList.toggle("toolbar-collapsed", collapsed); + toggleToolbarBtn.setAttribute("aria-pressed", String(collapsed)); + toggleToolbarBtn.title = collapsed ? "Show toolbar" : "Hide toolbar"; + toggleToolbarBtn.querySelector(".pane-toggle-icon").textContent = collapsed ? "▼" : "▲"; +} +function applyEditorCollapsed(collapsed) { + appRoot.classList.toggle("editor-collapsed", collapsed); + toggleEditorBtn.setAttribute("aria-pressed", String(collapsed)); + toggleEditorBtn.title = collapsed ? "Show content editor" : "Hide content editor"; + toggleEditorBtn.querySelector(".pane-toggle-icon").textContent = collapsed ? "▲" : "▼"; +} +applyToolbarCollapsed(localStorage.getItem(TOOLBAR_KEY) === "1"); +applyEditorCollapsed(localStorage.getItem(EDITOR_COLLAPSED_KEY) === "1"); +toggleToolbarBtn.addEventListener("click", () => { + const next = !appRoot.classList.contains("toolbar-collapsed"); + applyToolbarCollapsed(next); + localStorage.setItem(TOOLBAR_KEY, next ? "1" : "0"); +}); +toggleEditorBtn.addEventListener("click", () => { + const next = !appRoot.classList.contains("editor-collapsed"); + applyEditorCollapsed(next); + localStorage.setItem(EDITOR_COLLAPSED_KEY, next ? "1" : "0"); +}); + +init(); diff --git a/.github/extensions/terminal-mockup/assets/html2canvas.min.js b/.github/extensions/terminal-mockup/assets/html2canvas.min.js new file mode 100644 index 00000000000..aed6bfd70de --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/html2canvas.min.js @@ -0,0 +1,20 @@ +/*! + * html2canvas 1.4.1 + * Copyright (c) 2022 Niklas von Hertzen + * Released under MIT License + */ +!function(A,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(A="undefined"!=typeof globalThis?globalThis:A||self).html2canvas=e()}(this,function(){"use strict"; +/*! ***************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */var r=function(A,e){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(A,e){A.__proto__=e}||function(A,e){for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&(A[t]=e[t])})(A,e)};function A(A,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function t(){this.constructor=A}r(A,e),A.prototype=null===e?Object.create(e):(t.prototype=e.prototype,new t)}var h=function(){return(h=Object.assign||function(A){for(var e,t=1,r=arguments.length;ts[0]&&e[1]>10),s%1024+56320)),(B+1===t||16384>5],this.data[e=(e<<2)+(31&A)];if(A<=65535)return e=this.index[2048+(A-55296>>5)],this.data[e=(e<<2)+(31&A)];if(A>11)],e=this.index[e+=A>>5&63],this.data[e=(e<<2)+(31&A)];if(A<=1114111)return this.data[this.highValueIndex]}return this.errorValue},l);function l(A,e,t,r,B,n){this.initialValue=A,this.errorValue=e,this.highStart=t,this.highValueIndex=r,this.index=B,this.data=n}for(var C="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u="undefined"==typeof Uint8Array?[]:new Uint8Array(256),F=0;F>4,i[o++]=(15&t)<<4|r>>2,i[o++]=(3&r)<<6|63&B;return n}(y="KwAAAAAAAAAACA4AUD0AADAgAAACAAAAAAAIABAAGABAAEgAUABYAGAAaABgAGgAYgBqAF8AZwBgAGgAcQB5AHUAfQCFAI0AlQCdAKIAqgCyALoAYABoAGAAaABgAGgAwgDKAGAAaADGAM4A0wDbAOEA6QDxAPkAAQEJAQ8BFwF1AH0AHAEkASwBNAE6AUIBQQFJAVEBWQFhAWgBcAF4ATAAgAGGAY4BlQGXAZ8BpwGvAbUBvQHFAc0B0wHbAeMB6wHxAfkBAQIJAvEBEQIZAiECKQIxAjgCQAJGAk4CVgJeAmQCbAJ0AnwCgQKJApECmQKgAqgCsAK4ArwCxAIwAMwC0wLbAjAA4wLrAvMC+AIAAwcDDwMwABcDHQMlAy0DNQN1AD0DQQNJA0kDSQNRA1EDVwNZA1kDdQB1AGEDdQBpA20DdQN1AHsDdQCBA4kDkQN1AHUAmQOhA3UAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AKYDrgN1AHUAtgO+A8YDzgPWAxcD3gPjA+sD8wN1AHUA+wMDBAkEdQANBBUEHQQlBCoEFwMyBDgEYABABBcDSARQBFgEYARoBDAAcAQzAXgEgASIBJAEdQCXBHUAnwSnBK4EtgS6BMIEyAR1AHUAdQB1AHUAdQCVANAEYABgAGAAYABgAGAAYABgANgEYADcBOQEYADsBPQE/AQEBQwFFAUcBSQFLAU0BWQEPAVEBUsFUwVbBWAAYgVgAGoFcgV6BYIFigWRBWAAmQWfBaYFYABgAGAAYABgAKoFYACxBbAFuQW6BcEFwQXHBcEFwQXPBdMF2wXjBeoF8gX6BQIGCgYSBhoGIgYqBjIGOgZgAD4GRgZMBmAAUwZaBmAAYABgAGAAYABgAGAAYABgAGAAYABgAGIGYABpBnAGYABgAGAAYABgAGAAYABgAGAAYAB4Bn8GhQZgAGAAYAB1AHcDFQSLBmAAYABgAJMGdQA9A3UAmwajBqsGqwaVALMGuwbDBjAAywbSBtIG1QbSBtIG0gbSBtIG0gbdBuMG6wbzBvsGAwcLBxMHAwcbByMHJwcsBywHMQcsB9IGOAdAB0gHTgfSBkgHVgfSBtIG0gbSBtIG0gbSBtIG0gbSBiwHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAdgAGAALAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAdbB2MHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsB2kH0gZwB64EdQB1AHUAdQB1AHUAdQB1AHUHfQdgAIUHjQd1AHUAlQedB2AAYAClB6sHYACzB7YHvgfGB3UAzgfWBzMB3gfmB1EB7gf1B/0HlQENAQUIDQh1ABUIHQglCBcDLQg1CD0IRQhNCEEDUwh1AHUAdQBbCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIcAh3CHoIMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIgggwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAALAcsBywHLAcsBywHLAcsBywHLAcsB4oILAcsB44I0gaWCJ4Ipgh1AHUAqgiyCHUAdQB1AHUAdQB1AHUAdQB1AHUAtwh8AXUAvwh1AMUIyQjRCNkI4AjoCHUAdQB1AO4I9gj+CAYJDgkTCS0HGwkjCYIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiAAIAAAAFAAYABgAGIAXwBgAHEAdQBFAJUAogCyAKAAYABgAEIA4ABGANMA4QDxAMEBDwE1AFwBLAE6AQEBUQF4QkhCmEKoQrhCgAHIQsAB0MLAAcABwAHAAeDC6ABoAHDCwMMAAcABwAHAAdDDGMMAAcAB6MM4wwjDWMNow3jDaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAEjDqABWw6bDqABpg6gAaABoAHcDvwOPA+gAaABfA/8DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DpcPAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcAB9cPKwkyCToJMAB1AHUAdQBCCUoJTQl1AFUJXAljCWcJawkwADAAMAAwAHMJdQB2CX4JdQCECYoJjgmWCXUAngkwAGAAYABxAHUApgn3A64JtAl1ALkJdQDACTAAMAAwADAAdQB1AHUAdQB1AHUAdQB1AHUAowYNBMUIMAAwADAAMADICcsJ0wnZCRUE4QkwAOkJ8An4CTAAMAB1AAAKvwh1AAgKDwoXCh8KdQAwACcKLgp1ADYKqAmICT4KRgowADAAdQB1AE4KMAB1AFYKdQBeCnUAZQowADAAMAAwADAAMAAwADAAMAAVBHUAbQowADAAdQC5CXUKMAAwAHwBxAijBogEMgF9CoQKiASMCpQKmgqIBKIKqgquCogEDQG2Cr4KxgrLCjAAMADTCtsKCgHjCusK8Qr5CgELMAAwADAAMAB1AIsECQsRC3UANAEZCzAAMAAwADAAMAB1ACELKQswAHUANAExCzkLdQBBC0kLMABRC1kLMAAwADAAMAAwADAAdQBhCzAAMAAwAGAAYABpC3ELdwt/CzAAMACHC4sLkwubC58Lpwt1AK4Ltgt1APsDMAAwADAAMAAwADAAMAAwAL4LwwvLC9IL1wvdCzAAMADlC+kL8Qv5C/8LSQswADAAMAAwADAAMAAwADAAMAAHDDAAMAAwADAAMAAODBYMHgx1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1ACYMMAAwADAAdQB1AHUALgx1AHUAdQB1AHUAdQA2DDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AD4MdQBGDHUAdQB1AHUAdQB1AEkMdQB1AHUAdQB1AFAMMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQBYDHUAdQB1AF8MMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUA+wMVBGcMMAAwAHwBbwx1AHcMfwyHDI8MMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAYABgAJcMMAAwADAAdQB1AJ8MlQClDDAAMACtDCwHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsB7UMLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AA0EMAC9DDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAsBywHLAcsBywHLAcsBywHLQcwAMEMyAwsBywHLAcsBywHLAcsBywHLAcsBywHzAwwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1ANQM2QzhDDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMABgAGAAYABgAGAAYABgAOkMYADxDGAA+AwADQYNYABhCWAAYAAODTAAMAAwADAAFg1gAGAAHg37AzAAMAAwADAAYABgACYNYAAsDTQNPA1gAEMNPg1LDWAAYABgAGAAYABgAGAAYABgAGAAUg1aDYsGVglhDV0NcQBnDW0NdQ15DWAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAlQCBDZUAiA2PDZcNMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAnw2nDTAAMAAwADAAMAAwAHUArw23DTAAMAAwADAAMAAwADAAMAAwADAAMAB1AL8NMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAB1AHUAdQB1AHUAdQDHDTAAYABgAM8NMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAA1w11ANwNMAAwAD0B5A0wADAAMAAwADAAMADsDfQN/A0EDgwOFA4wABsOMAAwADAAMAAwADAAMAAwANIG0gbSBtIG0gbSBtIG0gYjDigOwQUuDsEFMw7SBjoO0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGQg5KDlIOVg7SBtIGXg5lDm0OdQ7SBtIGfQ6EDooOjQ6UDtIGmg6hDtIG0gaoDqwO0ga0DrwO0gZgAGAAYADEDmAAYAAkBtIGzA5gANIOYADaDokO0gbSBt8O5w7SBu8O0gb1DvwO0gZgAGAAxA7SBtIG0gbSBtIGYABgAGAAYAAED2AAsAUMD9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGFA8sBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAccD9IGLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHJA8sBywHLAcsBywHLAccDywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywPLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAc0D9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAccD9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGFA8sBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHPA/SBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gYUD0QPlQCVAJUAMAAwADAAMACVAJUAlQCVAJUAlQCVAEwPMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAA//8EAAQABAAEAAQABAAEAAQABAANAAMAAQABAAIABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQACgATABcAHgAbABoAHgAXABYAEgAeABsAGAAPABgAHABLAEsASwBLAEsASwBLAEsASwBLABgAGAAeAB4AHgATAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQABYAGwASAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAWAA0AEQAeAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAFAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAJABYAGgAbABsAGwAeAB0AHQAeAE8AFwAeAA0AHgAeABoAGwBPAE8ADgBQAB0AHQAdAE8ATwAXAE8ATwBPABYAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAFAAUABQAFAAUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAB4AHgAeAFAATwBAAE8ATwBPAEAATwBQAFAATwBQAB4AHgAeAB4AHgAeAB0AHQAdAB0AHgAdAB4ADgBQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgBQAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAJAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAkACQAJAAkACQAJAAkABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAFAAHgAeAB4AKwArAFAAUABQAFAAGABQACsAKwArACsAHgAeAFAAHgBQAFAAUAArAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAUAAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAYAA0AKwArAB4AHgAbACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQADQAEAB4ABAAEAB4ABAAEABMABAArACsAKwArACsAKwArACsAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAKwArACsAKwBWAFYAVgBWAB4AHgArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AGgAaABoAGAAYAB4AHgAEAAQABAAEAAQABAAEAAQABAAEAAQAEwAEACsAEwATAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABLAEsASwBLAEsASwBLAEsASwBLABoAGQAZAB4AUABQAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQABMAUAAEAAQABAAEAAQABAAEAB4AHgAEAAQABAAEAAQABABQAFAABAAEAB4ABAAEAAQABABQAFAASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUAAeAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAFAABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQAUABQAB4AHgAYABMAUAArACsABAAbABsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAFAABAAEAAQABAAEAFAABAAEAAQAUAAEAAQABAAEAAQAKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAArACsAHgArAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAUAAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAABAAEAA0ADQBLAEsASwBLAEsASwBLAEsASwBLAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUAArACsAKwBQAFAAUABQACsAKwAEAFAABAAEAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABABQACsAKwArACsAKwArACsAKwAEACsAKwArACsAUABQACsAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAFAAUAAaABoAUABQAFAAUABQAEwAHgAbAFAAHgAEACsAKwAEAAQABAArAFAAUABQAFAAUABQACsAKwArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQACsAUABQACsAKwAEACsABAAEAAQABAAEACsAKwArACsABAAEACsAKwAEAAQABAArACsAKwAEACsAKwArACsAKwArACsAUABQAFAAUAArAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLAAQABABQAFAAUAAEAB4AKwArACsAKwArACsAKwArACsAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQACsAKwAEAFAABAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAArACsAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAB4AGwArACsAKwArACsAKwArAFAABAAEAAQABAAEAAQAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABAArACsAKwArACsAKwArAAQABAAEACsAKwArACsAUABQACsAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAB4AUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAAQAUAArAFAAUABQAFAAUABQACsAKwArAFAAUABQACsAUABQAFAAUAArACsAKwBQAFAAKwBQACsAUABQACsAKwArAFAAUAArACsAKwBQAFAAUAArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArAAQABAAEAAQABAArACsAKwAEAAQABAArAAQABAAEAAQAKwArAFAAKwArACsAKwArACsABAArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAHgAeAB4AHgAeAB4AGwAeACsAKwArACsAKwAEAAQABAAEAAQAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAUAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAAEACsAKwArACsAKwArACsABAAEACsAUABQAFAAKwArACsAKwArAFAAUAAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAKwAOAFAAUABQAFAAUABQAFAAHgBQAAQABAAEAA4AUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAKwArAAQAUAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAAEACsAKwArACsAKwArACsABAAEACsAKwArACsAKwArACsAUAArAFAAUAAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwBQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAFAABAAEAAQABAAEAAQABAArAAQABAAEACsABAAEAAQABABQAB4AKwArACsAKwBQAFAAUAAEAFAAUABQAFAAUABQAFAAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAFAAUABQAFAAUABQAFAAUABQABoAUABQAFAAUABQAFAAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQACsAUAArACsAUABQAFAAUABQAFAAUAArACsAKwAEACsAKwArACsABAAEAAQABAAEAAQAKwAEACsABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArAAQABAAeACsAKwArACsAKwArACsAKwArACsAKwArAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAAqAFwAXAAqACoAKgAqACoAKgAqACsAKwArACsAGwBcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAeAEsASwBLAEsASwBLAEsASwBLAEsADQANACsAKwArACsAKwBcAFwAKwBcACsAXABcAFwAXABcACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACsAXAArAFwAXABcAFwAXABcAFwAXABcAFwAKgBcAFwAKgAqACoAKgAqACoAKgAqACoAXAArACsAXABcAFwAXABcACsAXAArACoAKgAqACoAKgAqACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwBcAFwAXABcAFAADgAOAA4ADgAeAA4ADgAJAA4ADgANAAkAEwATABMAEwATAAkAHgATAB4AHgAeAAQABAAeAB4AHgAeAB4AHgBLAEsASwBLAEsASwBLAEsASwBLAFAAUABQAFAAUABQAFAAUABQAFAADQAEAB4ABAAeAAQAFgARABYAEQAEAAQAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQADQAEAAQABAAEAAQADQAEAAQAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArAA0ADQAeAB4AHgAeAB4AHgAEAB4AHgAeAB4AHgAeACsAHgAeAA4ADgANAA4AHgAeAB4AHgAeAAkACQArACsAKwArACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgBcAEsASwBLAEsASwBLAEsASwBLAEsADQANAB4AHgAeAB4AXABcAFwAXABcAFwAKgAqACoAKgBcAFwAXABcACoAKgAqAFwAKgAqACoAXABcACoAKgAqACoAKgAqACoAXABcAFwAKgAqACoAKgBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKgAqAFwAKgBLAEsASwBLAEsASwBLAEsASwBLACoAKgAqACoAKgAqAFAAUABQAFAAUABQACsAUAArACsAKwArACsAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgBQAFAAUABQAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUAArACsAUABQAFAAUABQAFAAUAArAFAAKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAKwBQACsAUABQAFAAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsABAAEAAQAHgANAB4AHgAeAB4AHgAeAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUAArACsADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAANAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAWABEAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAA0ADQANAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAANAA0AKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUAArAAQABAArACsAKwArACsAKwArACsAKwArACsAKwBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqAA0ADQAVAFwADQAeAA0AGwBcACoAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwAeAB4AEwATAA0ADQAOAB4AEwATAB4ABAAEAAQACQArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUAAEAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAHgArACsAKwATABMASwBLAEsASwBLAEsASwBLAEsASwBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAArACsAXABcAFwAXABcACsAKwArACsAKwArACsAKwArACsAKwBcAFwAXABcAFwAXABcAFwAXABcAFwAXAArACsAKwArAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAXAArACsAKwAqACoAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAArACsAHgAeAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKwAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKwArAAQASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArACoAKgAqACoAKgAqACoAXAAqACoAKgAqACoAKgArACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABABQAFAAUABQAFAAUABQACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwANAA0AHgANAA0ADQANAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAEAAQAHgAeAB4AHgAeAB4AHgAeAB4AKwArACsABAAEAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwAeAB4AHgAeAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArAA0ADQANAA0ADQBLAEsASwBLAEsASwBLAEsASwBLACsAKwArAFAAUABQAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAA0ADQBQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUAAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArAAQABAAEAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAAQAUABQAFAAUABQAFAABABQAFAABAAEAAQAUAArACsAKwArACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsABAAEAAQABAAEAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAKwBQACsAUAArAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgBQAB4AHgAeAFAAUABQACsAHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQACsAKwAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQACsAHgAeAB4AHgAeAB4AHgAOAB4AKwANAA0ADQANAA0ADQANAAkADQANAA0ACAAEAAsABAAEAA0ACQANAA0ADAAdAB0AHgAXABcAFgAXABcAFwAWABcAHQAdAB4AHgAUABQAFAANAAEAAQAEAAQABAAEAAQACQAaABoAGgAaABoAGgAaABoAHgAXABcAHQAVABUAHgAeAB4AHgAeAB4AGAAWABEAFQAVABUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ADQAeAA0ADQANAA0AHgANAA0ADQAHAB4AHgAeAB4AKwAEAAQABAAEAAQABAAEAAQABAAEAFAAUAArACsATwBQAFAAUABQAFAAHgAeAB4AFgARAE8AUABPAE8ATwBPAFAAUABQAFAAUAAeAB4AHgAWABEAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArABsAGwAbABsAGwAbABsAGgAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGgAbABsAGwAbABoAGwAbABoAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAHgAeAFAAGgAeAB0AHgBQAB4AGgAeAB4AHgAeAB4AHgAeAB4AHgBPAB4AUAAbAB4AHgBQAFAAUABQAFAAHgAeAB4AHQAdAB4AUAAeAFAAHgBQAB4AUABPAFAAUAAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAHgBQAFAAUABQAE8ATwBQAFAAUABQAFAATwBQAFAATwBQAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAFAAUABQAFAATwBPAE8ATwBPAE8ATwBPAE8ATwBQAFAAUABQAFAAUABQAFAAUAAeAB4AUABQAFAAUABPAB4AHgArACsAKwArAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB4AHQAdAB4AHgAeAB0AHQAeAB4AHQAeAB4AHgAdAB4AHQAbABsAHgAdAB4AHgAeAB4AHQAeAB4AHQAdAB0AHQAeAB4AHQAeAB0AHgAdAB0AHQAdAB0AHQAeAB0AHgAeAB4AHgAeAB0AHQAdAB0AHgAeAB4AHgAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB4AHgAeAB0AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHgAeAB0AHQAdAB0AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAeAB4AHgAdAB4AHgAeAB4AHgAeAB4AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABYAEQAWABEAHgAeAB4AHgAeAB4AHQAeAB4AHgAeAB4AHgAeACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAWABEAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAFAAHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAeAB4AHQAdAB0AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHQAdAB4AHgAeAB4AHQAdAB0AHgAeAB0AHgAeAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlAB4AHQAdAB4AHgAdAB4AHgAeAB4AHQAdAB4AHgAeAB4AJQAlAB0AHQAlAB4AJQAlACUAIAAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAeAB4AHgAeAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHgAdAB0AHQAeAB0AJQAdAB0AHgAdAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAdAB0AHQAdACUAHgAlACUAJQAdACUAJQAdAB0AHQAlACUAHQAdACUAHQAdACUAJQAlAB4AHQAeAB4AHgAeAB0AHQAlAB0AHQAdAB0AHQAdACUAJQAlACUAJQAdACUAJQAgACUAHQAdACUAJQAlACUAJQAlACUAJQAeAB4AHgAlACUAIAAgACAAIAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AFwAXABcAFwAXABcAHgATABMAJQAeAB4AHgAWABEAFgARABYAEQAWABEAFgARABYAEQAWABEATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABYAEQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAWABEAFgARABYAEQAWABEAFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFgARABYAEQAWABEAFgARABYAEQAWABEAFgARABYAEQAWABEAFgARABYAEQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAWABEAFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAEAAQABAAeAB4AKwArACsAKwArABMADQANAA0AUAATAA0AUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAUAANACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAA0ADQANAA0ADQANAA0ADQAeAA0AFgANAB4AHgAXABcAHgAeABcAFwAWABEAFgARABYAEQAWABEADQANAA0ADQATAFAADQANAB4ADQANAB4AHgAeAB4AHgAMAAwADQANAA0AHgANAA0AFgANAA0ADQANAA0ADQANAA0AHgANAB4ADQANAB4AHgAeACsAKwArACsAKwArACsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwArACsAKwArACsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArAA0AEQARACUAJQBHAFcAVwAWABEAFgARABYAEQAWABEAFgARACUAJQAWABEAFgARABYAEQAWABEAFQAWABEAEQAlAFcAVwBXAFcAVwBXAFcAVwBXAAQABAAEAAQABAAEACUAVwBXAFcAVwA2ACUAJQBXAFcAVwBHAEcAJQAlACUAKwBRAFcAUQBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFEAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBRAFcAUQBXAFEAVwBXAFcAVwBXAFcAUQBXAFcAVwBXAFcAVwBRAFEAKwArAAQABAAVABUARwBHAFcAFQBRAFcAUQBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBRAFcAVwBXAFcAVwBXAFEAUQBXAFcAVwBXABUAUQBHAEcAVwArACsAKwArACsAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwAlACUAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACsAKwArACsAKwArACsAKwArACsAKwArAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBPAE8ATwBPAE8ATwBPAE8AJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQAlAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAEcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAADQATAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABLAEsASwBLAEsASwBLAEsASwBLAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAABAAEAAQABAAeAAQABAAEAAQABAAEAAQABAAEAAQAHgBQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUABQAAQABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAeAA0ADQANAA0ADQArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAB4AHgAeAB4AHgAeAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAHgAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAeAB4AUABQAFAAUABQAFAAUABQAFAAUABQAAQAUABQAFAABABQAFAAUABQAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAeAB4AHgAeAAQAKwArACsAUABQAFAAUABQAFAAHgAeABoAHgArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAADgAOABMAEwArACsAKwArACsAKwArACsABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwANAA0ASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAFAAUAAeAB4AHgBQAA4AUABQAAQAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAA0ADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArAB4AWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYACsAKwArAAQAHgAeAB4AHgAeAB4ADQANAA0AHgAeAB4AHgArAFAASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArAB4AHgBcAFwAXABcAFwAKgBcAFwAXABcAFwAXABcAFwAXABcAEsASwBLAEsASwBLAEsASwBLAEsAXABcAFwAXABcACsAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArAFAAUABQAAQAUABQAFAAUABQAFAAUABQAAQABAArACsASwBLAEsASwBLAEsASwBLAEsASwArACsAHgANAA0ADQBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKgAqACoAXAAqACoAKgBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAAqAFwAKgAqACoAXABcACoAKgBcAFwAXABcAFwAKgAqAFwAKgBcACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFwAXABcACoAKgBQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAA0ADQBQAFAAUAAEAAQAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUAArACsAUABQAFAAUABQAFAAKwArAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQADQAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAVABVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBUAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVACsAKwArACsAKwArACsAKwArACsAKwArAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAKwArACsAKwBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAKwArACsAKwAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAKwArACsAKwArAFYABABWAFYAVgBWAFYAVgBWAFYAVgBWAB4AVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgArAFYAVgBWAFYAVgArAFYAKwBWAFYAKwBWAFYAKwBWAFYAVgBWAFYAVgBWAFYAVgBWAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAEQAWAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAaAB4AKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAGAARABEAGAAYABMAEwAWABEAFAArACsAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACUAJQAlACUAJQAWABEAFgARABYAEQAWABEAFgARABYAEQAlACUAFgARACUAJQAlACUAJQAlACUAEQAlABEAKwAVABUAEwATACUAFgARABYAEQAWABEAJQAlACUAJQAlACUAJQAlACsAJQAbABoAJQArACsAKwArAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAcAKwATACUAJQAbABoAJQAlABYAEQAlACUAEQAlABEAJQBXAFcAVwBXAFcAVwBXAFcAVwBXABUAFQAlACUAJQATACUAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXABYAJQARACUAJQAlAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAWACUAEQAlABYAEQARABYAEQARABUAVwBRAFEAUQBRAFEAUQBRAFEAUQBRAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAEcARwArACsAVwBXAFcAVwBXAFcAKwArAFcAVwBXAFcAVwBXACsAKwBXAFcAVwBXAFcAVwArACsAVwBXAFcAKwArACsAGgAbACUAJQAlABsAGwArAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwAEAAQABAAQAB0AKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsADQANAA0AKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAB4AHgAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAAQAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAA0AUABQAFAAUAArACsAKwArAFAAUABQAFAAUABQAFAAUAANAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwAeACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAKwArAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUAArACsAKwBQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwANAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAB4AUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAUABQAFAAUABQAAQABAAEACsABAAEACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAKwBQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEACsAKwArACsABABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAA0ADQANAA0ADQANAA0ADQAeACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAArACsAKwArAFAAUABQAFAAUAANAA0ADQANAA0ADQAUACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsADQANAA0ADQANAA0ADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAAQABAAEAAQAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArAAQABAANACsAKwBQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAB4AHgAeAB4AHgArACsAKwArACsAKwAEAAQABAAEAAQABAAEAA0ADQAeAB4AHgAeAB4AKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgANAA0ADQANACsAKwArACsAKwArACsAKwArACsAKwAeACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsASwBLAEsASwBLAEsASwBLAEsASwANAA0ADQANAFAABAAEAFAAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAeAA4AUAArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAADQANAB4ADQAEAAQABAAEAB4ABAAEAEsASwBLAEsASwBLAEsASwBLAEsAUAAOAFAADQANAA0AKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAANAA0AHgANAA0AHgAEACsAUABQAFAAUABQAFAAUAArAFAAKwBQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAA0AKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsABAAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQACsABAAEAFAABAAEAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABAArACsAUAArACsAKwArACsAKwAEACsAKwArACsAKwBQAFAAUABQAFAABAAEACsAKwAEAAQABAAEAAQABAAEACsAKwArAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwArACsABAAEAAQABAAEAAQABABQAFAAUABQAA0ADQANAA0AHgBLAEsASwBLAEsASwBLAEsASwBLAA0ADQArAB4ABABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAFAAUAAeAFAAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAArACsABAAEAAQABAAEAAQABAAEAAQADgANAA0AEwATAB4AHgAeAA0ADQANAA0ADQANAA0ADQANAA0ADQANAA0ADQANAFAAUABQAFAABAAEACsAKwAEAA0ADQAeAFAAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKwArACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBcAFwADQANAA0AKgBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAKwArAFAAKwArAFAAUABQAFAAUABQAFAAUAArAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQAKwAEAAQAKwArAAQABAAEAAQAUAAEAFAABAAEAA0ADQANACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAArACsABAAEAAQABAAEAAQABABQAA4AUAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAFAABAAEAAQABAAOAB4ADQANAA0ADQAOAB4ABAArACsAKwArACsAKwArACsAUAAEAAQABAAEAAQABAAEAAQABAAEAAQAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAA0ADQANAFAADgAOAA4ADQANACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEACsABAAEAAQABAAEAAQABAAEAFAADQANAA0ADQANACsAKwArACsAKwArACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwAOABMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAArACsAKwAEACsABAAEACsABAAEAAQABAAEAAQABABQAAQAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAKwBQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQAKwAEAAQAKwAEAAQABAAEAAQAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAaABoAGgAaAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsADQANAA0ADQANACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAASABIAEgAQwBDAEMAUABQAFAAUABDAFAAUABQAEgAQwBIAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAASABDAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwAJAAkACQAJAAkACQAJABYAEQArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABIAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwANAA0AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEAAQABAANACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAA0ADQANAB4AHgAeAB4AHgAeAFAAUABQAFAADQAeACsAKwArACsAKwArACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAANAA0AHgAeACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwAEAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAARwBHABUARwAJACsAKwArACsAKwArACsAKwArACsAKwAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACsAKwArACsAKwArACsAKwBXAFcAVwBXAFcAVwBXAFcAVwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUQBRAFEAKwArACsAKwArACsAKwArACsAKwArACsAKwBRAFEAUQBRACsAKwArACsAKwArACsAKwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArACsAHgAEAAQADQAEAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArAB4AHgAeAB4AHgAeAB4AKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAAQABAAEAAQABAAeAB4AHgAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAB4AHgAEAAQABAAEAAQABAAEAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQAHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwBQAFAAKwArAFAAKwArAFAAUAArACsAUABQAFAAUAArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAUAArAFAAUABQAFAAUABQAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAHgAeAFAAUABQAFAAUAArAFAAKwArACsAUABQAFAAUABQAFAAUAArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeACsAKwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4ABAAeAB4AHgAeAB4AHgAeAB4AHgAeAAQAHgAeAA0ADQANAA0AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAAQAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArAAQABAAEAAQABAAEAAQAKwAEAAQAKwAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwBQAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArABsAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArAB4AHgAeAB4ABAAEAAQABAAEAAQABABQACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArABYAFgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAGgBQAFAAUAAaAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAKwBQACsAKwBQACsAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwBQACsAUAArACsAKwArACsAKwBQACsAKwArACsAUAArAFAAKwBQACsAUABQAFAAKwBQAFAAKwBQACsAKwBQACsAUAArAFAAKwBQACsAUAArAFAAUAArAFAAKwArAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUAArAFAAUABQAFAAKwBQACsAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAUABQAFAAKwBQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8AJQAlACUAHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB4AHgAeACUAJQAlAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlAB4AHgAlACUAJQAlACUAHgAlACUAJQAlACUAIAAgACAAJQAlACAAJQAlACAAIAAgACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACEAIQAhACEAIQAlACUAIAAgACUAJQAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlACUAIAAlACUAJQAlACAAIAAgACUAIAAgACAAJQAlACUAJQAlACUAJQAgACUAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAlAB4AJQAeACUAJQAlACUAJQAgACUAJQAlACUAHgAlAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAgACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACAAIAAgACUAJQAlACAAIAAgACAAIAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABcAFwAXABUAFQAVAB4AHgAeAB4AJQAlACUAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAgACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAgACUAJQAgACUAJQAlACUAJQAlACUAJQAgACAAIAAgACAAIAAgACAAJQAlACUAJQAlACUAIAAlACUAJQAlACUAJQAlACUAJQAgACAAIAAgACAAIAAgACAAIAAgACUAJQAgACAAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAgACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAlACAAIAAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAgACAAIAAlACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwArAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACUAVwBXACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAA=="),L=Array.isArray(m)?function(A){for(var e=A.length,t=[],r=0;r=this._value.length?-1:this._value[A]},XA.prototype.consumeUnicodeRangeToken=function(){for(var A=[],e=this.consumeCodePoint();lA(e)&&A.length<6;)A.push(e),e=this.consumeCodePoint();for(var t=!1;63===e&&A.length<6;)A.push(e),e=this.consumeCodePoint(),t=!0;if(t)return{type:30,start:parseInt(g.apply(void 0,A.map(function(A){return 63===A?48:A})),16),end:parseInt(g.apply(void 0,A.map(function(A){return 63===A?70:A})),16)};var r=parseInt(g.apply(void 0,A),16);if(45===this.peekCodePoint(0)&&lA(this.peekCodePoint(1))){this.consumeCodePoint();for(var e=this.consumeCodePoint(),B=[];lA(e)&&B.length<6;)B.push(e),e=this.consumeCodePoint();return{type:30,start:r,end:parseInt(g.apply(void 0,B),16)}}return{type:30,start:r,end:r}},XA.prototype.consumeIdentLikeToken=function(){var A=this.consumeName();return"url"===A.toLowerCase()&&40===this.peekCodePoint(0)?(this.consumeCodePoint(),this.consumeUrlToken()):40===this.peekCodePoint(0)?(this.consumeCodePoint(),{type:19,value:A}):{type:20,value:A}},XA.prototype.consumeUrlToken=function(){var A=[];if(this.consumeWhiteSpace(),-1===this.peekCodePoint(0))return{type:22,value:""};var e,t=this.peekCodePoint(0);if(39===t||34===t){t=this.consumeStringToken(this.consumeCodePoint());return 0===t.type&&(this.consumeWhiteSpace(),-1===this.peekCodePoint(0)||41===this.peekCodePoint(0))?(this.consumeCodePoint(),{type:22,value:t.value}):(this.consumeBadUrlRemnants(),xA)}for(;;){var r=this.consumeCodePoint();if(-1===r||41===r)return{type:22,value:g.apply(void 0,A)};if(CA(r))return this.consumeWhiteSpace(),-1===this.peekCodePoint(0)||41===this.peekCodePoint(0)?(this.consumeCodePoint(),{type:22,value:g.apply(void 0,A)}):(this.consumeBadUrlRemnants(),xA);if(34===r||39===r||40===r||(0<=(e=r)&&e<=8||11===e||14<=e&&e<=31||127===e))return this.consumeBadUrlRemnants(),xA;if(92===r){if(!hA(r,this.peekCodePoint(0)))return this.consumeBadUrlRemnants(),xA;A.push(this.consumeEscapedCodePoint())}else A.push(r)}},XA.prototype.consumeWhiteSpace=function(){for(;CA(this.peekCodePoint(0));)this.consumeCodePoint()},XA.prototype.consumeBadUrlRemnants=function(){for(;;){var A=this.consumeCodePoint();if(41===A||-1===A)return;hA(A,this.peekCodePoint(0))&&this.consumeEscapedCodePoint()}},XA.prototype.consumeStringSlice=function(A){for(var e="";0>8,r=255&A>>16,A=255&A>>24;return e<255?"rgba("+A+","+r+","+t+","+e/255+")":"rgb("+A+","+r+","+t+")"}function Qe(A,e){if(17===A.type)return A.number;if(16!==A.type)return 0;var t=3===e?1:255;return 3===e?A.number/100*t:Math.round(A.number/100*t)}var ce=function(A,e){return 11===e&&12===A.type||(28===e&&29===A.type||2===e&&3===A.type)},ae={type:17,number:0,flags:4},ge={type:16,number:50,flags:4},we={type:16,number:100,flags:4},Ue=function(A,e){if(16===A.type)return A.number/100*e;if(WA(A))switch(A.unit){case"rem":case"em":return 16*A.number;default:return A.number}return A.number},le=function(A,e){if(15===e.type)switch(e.unit){case"deg":return Math.PI*e.number/180;case"grad":return Math.PI/200*e.number;case"rad":return e.number;case"turn":return 2*Math.PI*e.number}throw new Error("Unsupported angle type")},Ce=function(A){return Math.PI*A/180},ue=function(A,e){if(18===e.type){var t=me[e.name];if(void 0===t)throw new Error('Attempting to parse an unsupported color function "'+e.name+'"');return t(A,e.values)}if(5===e.type){if(3===e.value.length){var r=e.value.substring(0,1),B=e.value.substring(1,2),n=e.value.substring(2,3);return Fe(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),1)}if(4===e.value.length){var r=e.value.substring(0,1),B=e.value.substring(1,2),n=e.value.substring(2,3),s=e.value.substring(3,4);return Fe(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),parseInt(s+s,16)/255)}if(6===e.value.length){r=e.value.substring(0,2),B=e.value.substring(2,4),n=e.value.substring(4,6);return Fe(parseInt(r,16),parseInt(B,16),parseInt(n,16),1)}if(8===e.value.length){r=e.value.substring(0,2),B=e.value.substring(2,4),n=e.value.substring(4,6),s=e.value.substring(6,8);return Fe(parseInt(r,16),parseInt(B,16),parseInt(n,16),parseInt(s,16)/255)}}if(20===e.type){e=Le[e.value.toUpperCase()];if(void 0!==e)return e}return Le.TRANSPARENT},Fe=function(A,e,t,r){return(A<<24|e<<16|t<<8|Math.round(255*r)<<0)>>>0},he=function(A,e){e=e.filter($A);if(3===e.length){var t=e.map(Qe),r=t[0],B=t[1],t=t[2];return Fe(r,B,t,1)}if(4!==e.length)return 0;e=e.map(Qe),r=e[0],B=e[1],t=e[2],e=e[3];return Fe(r,B,t,e)};function de(A,e,t){return t<0&&(t+=1),1<=t&&--t,t<1/6?(e-A)*t*6+A:t<.5?e:t<2/3?6*(e-A)*(2/3-t)+A:A}function fe(A,e){return ue(A,JA.create(e).parseComponentValue())}function He(A,e){return A=ue(A,e[0]),(e=e[1])&&te(e)?{color:A,stop:e}:{color:A,stop:null}}function pe(A,t){var e=A[0],r=A[A.length-1];null===e.stop&&(e.stop=ae),null===r.stop&&(r.stop=we);for(var B=[],n=0,s=0;sA.optimumDistance)?{optimumCorner:e,optimumDistance:r}:A},{optimumDistance:s?1/0:-1/0,optimumCorner:null}).optimumCorner}var Ke=function(A,e){var t=e.filter($A),r=t[0],B=t[1],n=t[2],e=t[3],t=(17===r.type?Ce(r.number):le(A,r))/(2*Math.PI),A=te(B)?B.number/100:0,r=te(n)?n.number/100:0,B=void 0!==e&&te(e)?Ue(e,1):1;if(0==A)return Fe(255*r,255*r,255*r,1);n=r<=.5?r*(1+A):r+A-r*A,e=2*r-n,A=de(e,n,t+1/3),r=de(e,n,t),t=de(e,n,t-1/3);return Fe(255*A,255*r,255*t,B)},me={hsl:Ke,hsla:Ke,rgb:he,rgba:he},Le={ALICEBLUE:4042850303,ANTIQUEWHITE:4209760255,AQUA:16777215,AQUAMARINE:2147472639,AZURE:4043309055,BEIGE:4126530815,BISQUE:4293182719,BLACK:255,BLANCHEDALMOND:4293643775,BLUE:65535,BLUEVIOLET:2318131967,BROWN:2771004159,BURLYWOOD:3736635391,CADETBLUE:1604231423,CHARTREUSE:2147418367,CHOCOLATE:3530104575,CORAL:4286533887,CORNFLOWERBLUE:1687547391,CORNSILK:4294499583,CRIMSON:3692313855,CYAN:16777215,DARKBLUE:35839,DARKCYAN:9145343,DARKGOLDENROD:3095837695,DARKGRAY:2846468607,DARKGREEN:6553855,DARKGREY:2846468607,DARKKHAKI:3182914559,DARKMAGENTA:2332068863,DARKOLIVEGREEN:1433087999,DARKORANGE:4287365375,DARKORCHID:2570243327,DARKRED:2332033279,DARKSALMON:3918953215,DARKSEAGREEN:2411499519,DARKSLATEBLUE:1211993087,DARKSLATEGRAY:793726975,DARKSLATEGREY:793726975,DARKTURQUOISE:13554175,DARKVIOLET:2483082239,DEEPPINK:4279538687,DEEPSKYBLUE:12582911,DIMGRAY:1768516095,DIMGREY:1768516095,DODGERBLUE:512819199,FIREBRICK:2988581631,FLORALWHITE:4294635775,FORESTGREEN:579543807,FUCHSIA:4278255615,GAINSBORO:3705462015,GHOSTWHITE:4177068031,GOLD:4292280575,GOLDENROD:3668254975,GRAY:2155905279,GREEN:8388863,GREENYELLOW:2919182335,GREY:2155905279,HONEYDEW:4043305215,HOTPINK:4285117695,INDIANRED:3445382399,INDIGO:1258324735,IVORY:4294963455,KHAKI:4041641215,LAVENDER:3873897215,LAVENDERBLUSH:4293981695,LAWNGREEN:2096890111,LEMONCHIFFON:4294626815,LIGHTBLUE:2916673279,LIGHTCORAL:4034953471,LIGHTCYAN:3774873599,LIGHTGOLDENRODYELLOW:4210742015,LIGHTGRAY:3553874943,LIGHTGREEN:2431553791,LIGHTGREY:3553874943,LIGHTPINK:4290167295,LIGHTSALMON:4288707327,LIGHTSEAGREEN:548580095,LIGHTSKYBLUE:2278488831,LIGHTSLATEGRAY:2005441023,LIGHTSLATEGREY:2005441023,LIGHTSTEELBLUE:2965692159,LIGHTYELLOW:4294959359,LIME:16711935,LIMEGREEN:852308735,LINEN:4210091775,MAGENTA:4278255615,MAROON:2147483903,MEDIUMAQUAMARINE:1724754687,MEDIUMBLUE:52735,MEDIUMORCHID:3126187007,MEDIUMPURPLE:2473647103,MEDIUMSEAGREEN:1018393087,MEDIUMSLATEBLUE:2070474495,MEDIUMSPRINGGREEN:16423679,MEDIUMTURQUOISE:1221709055,MEDIUMVIOLETRED:3340076543,MIDNIGHTBLUE:421097727,MINTCREAM:4127193855,MISTYROSE:4293190143,MOCCASIN:4293178879,NAVAJOWHITE:4292783615,NAVY:33023,OLDLACE:4260751103,OLIVE:2155872511,OLIVEDRAB:1804477439,ORANGE:4289003775,ORANGERED:4282712319,ORCHID:3664828159,PALEGOLDENROD:4008225535,PALEGREEN:2566625535,PALETURQUOISE:2951671551,PALEVIOLETRED:3681588223,PAPAYAWHIP:4293907967,PEACHPUFF:4292524543,PERU:3448061951,PINK:4290825215,PLUM:3718307327,POWDERBLUE:2967529215,PURPLE:2147516671,REBECCAPURPLE:1714657791,RED:4278190335,ROSYBROWN:3163525119,ROYALBLUE:1097458175,SADDLEBROWN:2336560127,SALMON:4202722047,SANDYBROWN:4104413439,SEAGREEN:780883967,SEASHELL:4294307583,SIENNA:2689740287,SILVER:3233857791,SKYBLUE:2278484991,SLATEBLUE:1784335871,SLATEGRAY:1887473919,SLATEGREY:1887473919,SNOW:4294638335,SPRINGGREEN:16744447,STEELBLUE:1182971135,TAN:3535047935,TEAL:8421631,THISTLE:3636451583,TOMATO:4284696575,TRANSPARENT:0,TURQUOISE:1088475391,VIOLET:4001558271,WHEAT:4125012991,WHITE:4294967295,WHITESMOKE:4126537215,YELLOW:4294902015,YELLOWGREEN:2597139199},be={name:"background-clip",initialValue:"border-box",prefix:!1,type:1,parse:function(A,e){return e.map(function(A){if(_A(A))switch(A.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},De={name:"background-color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ke=function(t,A){var r=Ce(180),B=[];return Ae(A).forEach(function(A,e){if(0===e){e=A[0];if(20===e.type&&-1!==["top","left","right","bottom"].indexOf(e.value))return void(r=se(A));if(ne(e))return void(r=(le(t,e)+Ce(270))%Ce(360))}A=He(t,A);B.push(A)}),{angle:r,stops:B,type:1}},ve="closest-side",xe="farthest-side",Me="closest-corner",Se="farthest-corner",Te="ellipse",Ge="contain",he=function(r,A){var B=0,n=3,s=[],o=[];return Ae(A).forEach(function(A,e){var t=!0;0===e?t=A.reduce(function(A,e){if(_A(e))switch(e.value){case"center":return o.push(ge),!1;case"top":case"left":return o.push(ae),!1;case"right":case"bottom":return o.push(we),!1}else if(te(e)||ee(e))return o.push(e),!1;return A},t):1===e&&(t=A.reduce(function(A,e){if(_A(e))switch(e.value){case"circle":return B=0,!1;case Te:return!(B=1);case Ge:case ve:return n=0,!1;case xe:return!(n=1);case Me:return!(n=2);case"cover":case Se:return!(n=3)}else if(ee(e)||te(e))return(n=!Array.isArray(n)?[]:n).push(e),!1;return A},t)),t&&(A=He(r,A),s.push(A))}),{size:n,shape:B,stops:s,position:o,type:2}},Oe=function(A,e){if(22===e.type){var t={url:e.value,type:0};return A.cache.addImage(e.value),t}if(18!==e.type)throw new Error("Unsupported image type "+e.type);t=ke[e.name];if(void 0===t)throw new Error('Attempting to parse an unsupported image function "'+e.name+'"');return t(A,e.values)};var Ve,ke={"linear-gradient":function(t,A){var r=Ce(180),B=[];return Ae(A).forEach(function(A,e){if(0===e){e=A[0];if(20===e.type&&"to"===e.value)return void(r=se(A));if(ne(e))return void(r=le(t,e))}A=He(t,A);B.push(A)}),{angle:r,stops:B,type:1}},"-moz-linear-gradient":Ke,"-ms-linear-gradient":Ke,"-o-linear-gradient":Ke,"-webkit-linear-gradient":Ke,"radial-gradient":function(B,A){var n=0,s=3,o=[],i=[];return Ae(A).forEach(function(A,e){var t,r=!0;0===e&&(t=!1,r=A.reduce(function(A,e){if(t)if(_A(e))switch(e.value){case"center":return i.push(ge),A;case"top":case"left":return i.push(ae),A;case"right":case"bottom":return i.push(we),A}else(te(e)||ee(e))&&i.push(e);else if(_A(e))switch(e.value){case"circle":return n=0,!1;case Te:return!(n=1);case"at":return!(t=!0);case ve:return s=0,!1;case"cover":case xe:return!(s=1);case Ge:case Me:return!(s=2);case Se:return!(s=3)}else if(ee(e)||te(e))return(s=!Array.isArray(s)?[]:s).push(e),!1;return A},r)),r&&(A=He(B,A),o.push(A))}),{size:s,shape:n,stops:o,position:i,type:2}},"-moz-radial-gradient":he,"-ms-radial-gradient":he,"-o-radial-gradient":he,"-webkit-radial-gradient":he,"-webkit-gradient":function(r,A){var e=Ce(180),B=[],n=1;return Ae(A).forEach(function(A,e){var t,A=A[0];if(0===e){if(_A(A)&&"linear"===A.value)return void(n=1);if(_A(A)&&"radial"===A.value)return void(n=2)}18===A.type&&("from"===A.name?(t=ue(r,A.values[0]),B.push({stop:ae,color:t})):"to"===A.name?(t=ue(r,A.values[0]),B.push({stop:we,color:t})):"color-stop"!==A.name||2===(A=A.values.filter($A)).length&&(t=ue(r,A[1]),A=A[0],ZA(A)&&B.push({stop:{type:16,number:100*A.number,flags:A.flags},color:t})))}),1===n?{angle:(e+Ce(180))%Ce(360),stops:B,type:n}:{size:3,shape:0,stops:B,position:[],type:n}}},Re={name:"background-image",initialValue:"none",type:1,prefix:!1,parse:function(e,A){if(0===A.length)return[];var t=A[0];return 20===t.type&&"none"===t.value?[]:A.filter(function(A){return $A(A)&&!(20===(A=A).type&&"none"===A.value||18===A.type&&!ke[A.name])}).map(function(A){return Oe(e,A)})}},Ne={name:"background-origin",initialValue:"border-box",prefix:!1,type:1,parse:function(A,e){return e.map(function(A){if(_A(A))switch(A.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},Pe={name:"background-position",initialValue:"0% 0%",type:1,prefix:!1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(te)}).map(re)}},Xe={name:"background-repeat",initialValue:"repeat",prefix:!1,type:1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(_A).map(function(A){return A.value}).join(" ")}).map(Je)}},Je=function(A){switch(A){case"no-repeat":return 1;case"repeat-x":case"repeat no-repeat":return 2;case"repeat-y":case"no-repeat repeat":return 3;default:return 0}};(he=Ve=Ve||{}).AUTO="auto",he.CONTAIN="contain";function Ye(A,e){return _A(A)&&"normal"===A.value?1.2*e:17===A.type?e*A.number:te(A)?Ue(A,e):e}var We,Ze,_e={name:"background-size",initialValue:"0",prefix:!(he.COVER="cover"),type:1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(qe)})}},qe=function(A){return _A(A)||te(A)},he=function(A){return{name:"border-"+A+"-color",initialValue:"transparent",prefix:!1,type:3,format:"color"}},je=he("top"),ze=he("right"),$e=he("bottom"),At=he("left"),he=function(A){return{name:"border-radius-"+A,initialValue:"0 0",prefix:!1,type:1,parse:function(A,e){return re(e.filter(te))}}},et=he("top-left"),tt=he("top-right"),rt=he("bottom-right"),Bt=he("bottom-left"),he=function(A){return{name:"border-"+A+"-style",initialValue:"solid",prefix:!1,type:2,parse:function(A,e){switch(e){case"none":return 0;case"dashed":return 2;case"dotted":return 3;case"double":return 4}return 1}}},nt=he("top"),st=he("right"),ot=he("bottom"),it=he("left"),he=function(A){return{name:"border-"+A+"-width",initialValue:"0",type:0,prefix:!1,parse:function(A,e){return WA(e)?e.number:0}}},Qt=he("top"),ct=he("right"),at=he("bottom"),gt=he("left"),wt={name:"color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ut={name:"direction",initialValue:"ltr",prefix:!1,type:2,parse:function(A,e){return"rtl"!==e?0:1}},lt={name:"display",initialValue:"inline-block",prefix:!1,type:1,parse:function(A,e){return e.filter(_A).reduce(function(A,e){return A|Ct(e.value)},0)}},Ct=function(A){switch(A){case"block":case"-webkit-box":return 2;case"inline":return 4;case"run-in":return 8;case"flow":return 16;case"flow-root":return 32;case"table":return 64;case"flex":case"-webkit-flex":return 128;case"grid":case"-ms-grid":return 256;case"ruby":return 512;case"subgrid":return 1024;case"list-item":return 2048;case"table-row-group":return 4096;case"table-header-group":return 8192;case"table-footer-group":return 16384;case"table-row":return 32768;case"table-cell":return 65536;case"table-column-group":return 131072;case"table-column":return 262144;case"table-caption":return 524288;case"ruby-base":return 1048576;case"ruby-text":return 2097152;case"ruby-base-container":return 4194304;case"ruby-text-container":return 8388608;case"contents":return 16777216;case"inline-block":return 33554432;case"inline-list-item":return 67108864;case"inline-table":return 134217728;case"inline-flex":return 268435456;case"inline-grid":return 536870912}return 0},ut={name:"float",initialValue:"none",prefix:!1,type:2,parse:function(A,e){switch(e){case"left":return 1;case"right":return 2;case"inline-start":return 3;case"inline-end":return 4}return 0}},Ft={name:"letter-spacing",initialValue:"0",prefix:!1,type:0,parse:function(A,e){return!(20===e.type&&"normal"===e.value||17!==e.type&&15!==e.type)?e.number:0}},ht={name:"line-break",initialValue:(he=We=We||{}).NORMAL="normal",prefix:!(he.STRICT="strict"),type:2,parse:function(A,e){return"strict"!==e?We.NORMAL:We.STRICT}},dt={name:"line-height",initialValue:"normal",prefix:!1,type:4},ft={name:"list-style-image",initialValue:"none",type:0,prefix:!1,parse:function(A,e){return 20===e.type&&"none"===e.value?null:Oe(A,e)}},Ht={name:"list-style-position",initialValue:"outside",prefix:!1,type:2,parse:function(A,e){return"inside"!==e?1:0}},pt={name:"list-style-type",initialValue:"none",prefix:!1,type:2,parse:function(A,e){switch(e){case"disc":return 0;case"circle":return 1;case"square":return 2;case"decimal":return 3;case"cjk-decimal":return 4;case"decimal-leading-zero":return 5;case"lower-roman":return 6;case"upper-roman":return 7;case"lower-greek":return 8;case"lower-alpha":return 9;case"upper-alpha":return 10;case"arabic-indic":return 11;case"armenian":return 12;case"bengali":return 13;case"cambodian":return 14;case"cjk-earthly-branch":return 15;case"cjk-heavenly-stem":return 16;case"cjk-ideographic":return 17;case"devanagari":return 18;case"ethiopic-numeric":return 19;case"georgian":return 20;case"gujarati":return 21;case"gurmukhi":case"hebrew":return 22;case"hiragana":return 23;case"hiragana-iroha":return 24;case"japanese-formal":return 25;case"japanese-informal":return 26;case"kannada":return 27;case"katakana":return 28;case"katakana-iroha":return 29;case"khmer":return 30;case"korean-hangul-formal":return 31;case"korean-hanja-formal":return 32;case"korean-hanja-informal":return 33;case"lao":return 34;case"lower-armenian":return 35;case"malayalam":return 36;case"mongolian":return 37;case"myanmar":return 38;case"oriya":return 39;case"persian":return 40;case"simp-chinese-formal":return 41;case"simp-chinese-informal":return 42;case"tamil":return 43;case"telugu":return 44;case"thai":return 45;case"tibetan":return 46;case"trad-chinese-formal":return 47;case"trad-chinese-informal":return 48;case"upper-armenian":return 49;case"disclosure-open":return 50;case"disclosure-closed":return 51;default:return-1}}},he=function(A){return{name:"margin-"+A,initialValue:"0",prefix:!1,type:4}},Et=he("top"),It=he("right"),yt=he("bottom"),Kt=he("left"),mt={name:"overflow",initialValue:"visible",prefix:!1,type:1,parse:function(A,e){return e.filter(_A).map(function(A){switch(A.value){case"hidden":return 1;case"scroll":return 2;case"clip":return 3;case"auto":return 4;default:return 0}})}},Lt={name:"overflow-wrap",initialValue:"normal",prefix:!1,type:2,parse:function(A,e){return"break-word"!==e?"normal":"break-word"}},he=function(A){return{name:"padding-"+A,initialValue:"0",prefix:!1,type:3,format:"length-percentage"}},bt=he("top"),Dt=he("right"),vt=he("bottom"),xt=he("left"),Mt={name:"text-align",initialValue:"left",prefix:!1,type:2,parse:function(A,e){switch(e){case"right":return 2;case"center":case"justify":return 1;default:return 0}}},St={name:"position",initialValue:"static",prefix:!1,type:2,parse:function(A,e){switch(e){case"relative":return 1;case"absolute":return 2;case"fixed":return 3;case"sticky":return 4}return 0}},Tt={name:"text-shadow",initialValue:"none",type:1,prefix:!1,parse:function(n,A){return 1===A.length&&jA(A[0],"none")?[]:Ae(A).map(function(A){for(var e={color:Le.TRANSPARENT,offsetX:ae,offsetY:ae,blur:ae},t=0,r=0;r>5],this.data[e=(e<<2)+(31&A)];if(A<=65535)return e=this.index[2048+(A-55296>>5)],this.data[e=(e<<2)+(31&A)];if(A>11)],e=this.index[e+=A>>5&63],this.data[e=(e<<2)+(31&A)];if(A<=1114111)return this.data[this.highValueIndex]}return this.errorValue},pr);function pr(A,e,t,r,B,n){this.initialValue=A,this.errorValue=e,this.highStart=t,this.highValueIndex=r,this.index=B,this.data=n}for(var Er="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",Ir="undefined"==typeof Uint8Array?[]:new Uint8Array(256),yr=0;yr>10),s%1024+56320)),(B+1===t||16384>4,i[o++]=(15&t)<<4|r>>2,i[o++]=(3&r)<<6|63&B;return n}(br="AAAAAAAAAAAAEA4AGBkAAFAaAAACAAAAAAAIABAAGAAwADgACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAAQABIAEQATAAIABAACAAQAAgAEAAIABAAVABcAAgAEAAIABAACAAQAGAAaABwAHgAgACIAI4AlgAIABAAmwCjAKgAsAC2AL4AvQDFAMoA0gBPAVYBWgEIAAgACACMANoAYgFkAWwBdAF8AX0BhQGNAZUBlgGeAaMBlQGWAasBswF8AbsBwwF0AcsBYwHTAQgA2wG/AOMBdAF8AekB8QF0AfkB+wHiAHQBfAEIAAMC5gQIAAsCEgIIAAgAFgIeAggAIgIpAggAMQI5AkACygEIAAgASAJQAlgCYAIIAAgACAAKBQoFCgUTBRMFGQUrBSsFCAAIAAgACAAIAAgACAAIAAgACABdAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABoAmgCrwGvAQgAbgJ2AggAHgEIAAgACADnAXsCCAAIAAgAgwIIAAgACAAIAAgACACKAggAkQKZAggAPADJAAgAoQKkAqwCsgK6AsICCADJAggA0AIIAAgACAAIANYC3gIIAAgACAAIAAgACABAAOYCCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAkASoB+QIEAAgACAA8AEMCCABCBQgACABJBVAFCAAIAAgACAAIAAgACAAIAAgACABTBVoFCAAIAFoFCABfBWUFCAAIAAgACAAIAAgAbQUIAAgACAAIAAgACABzBXsFfQWFBYoFigWKBZEFigWKBYoFmAWfBaYFrgWxBbkFCAAIAAgACAAIAAgACAAIAAgACAAIAMEFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAMgFCADQBQgACAAIAAgACAAIAAgACAAIAAgACAAIAO4CCAAIAAgAiQAIAAgACABAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAD0AggACAD8AggACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIANYFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAMDvwAIAAgAJAIIAAgACAAIAAgACAAIAAgACwMTAwgACAB9BOsEGwMjAwgAKwMyAwsFYgE3A/MEPwMIAEUDTQNRAwgAWQOsAGEDCAAIAAgACAAIAAgACABpAzQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFIQUoBSwFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABtAwgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABMAEwACAAIAAgACAAIABgACAAIAAgACAC/AAgACAAyAQgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACAAIAAwAAgACAAIAAgACAAIAAgACAAIAAAARABIAAgACAAIABQASAAIAAgAIABwAEAAjgCIABsAqAC2AL0AigDQAtwC+IJIQqVAZUBWQqVAZUBlQGVAZUBlQGrC5UBlQGVAZUBlQGVAZUBlQGVAXsKlQGVAbAK6wsrDGUMpQzlDJUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAfAKAAuZA64AtwCJALoC6ADwAAgAuACgA/oEpgO6AqsD+AAIAAgAswMIAAgACAAIAIkAuwP5AfsBwwPLAwgACAAIAAgACADRA9kDCAAIAOED6QMIAAgACAAIAAgACADuA/YDCAAIAP4DyQAIAAgABgQIAAgAXQAOBAgACAAIAAgACAAIABMECAAIAAgACAAIAAgACAD8AAQBCAAIAAgAGgQiBCoECAExBAgAEAEIAAgACAAIAAgACAAIAAgACAAIAAgACAA4BAgACABABEYECAAIAAgATAQYAQgAVAQIAAgACAAIAAgACAAIAAgACAAIAFoECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAOQEIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAB+BAcACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAEABhgSMBAgACAAIAAgAlAQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAwAEAAQABAADAAMAAwADAAQABAAEAAQABAAEAAQABHATAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAdQMIAAgACAAIAAgACAAIAMkACAAIAAgAfQMIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACFA4kDCAAIAAgACAAIAOcBCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAIcDCAAIAAgACAAIAAgACAAIAAgACAAIAJEDCAAIAAgACADFAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABgBAgAZgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAbAQCBXIECAAIAHkECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABAAJwEQACjBKoEsgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAC6BMIECAAIAAgACAAIAAgACABmBAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAxwQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAGYECAAIAAgAzgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBd0FXwUIAOIF6gXxBYoF3gT5BQAGCAaKBYoFigWKBYoFigWKBYoFigWKBYoFigXWBIoFigWKBYoFigWKBYoFigWKBYsFEAaKBYoFigWKBYoFigWKBRQGCACKBYoFigWKBQgACAAIANEECAAIABgGigUgBggAJgYIAC4GMwaKBYoF0wQ3Bj4GigWKBYoFigWKBYoFigWKBYoFigWKBYoFigUIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWLBf///////wQABAAEAAQABAAEAAQABAAEAAQAAwAEAAQAAgAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAQADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUAAAAFAAUAAAAFAAUAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAQAAAAUABQAFAAUABQAFAAAAAAAFAAUAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAFAAUAAQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAAABwAHAAcAAAAHAAcABwAFAAEAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAcABwAFAAUAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAQABAAAAAAAAAAAAAAAFAAUABQAFAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAHAAcAAAAHAAcAAAAAAAUABQAHAAUAAQAHAAEABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwABAAUABQAFAAUAAAAAAAAAAAAAAAEAAQABAAEAAQABAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABQANAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAABQAHAAUABQAFAAAAAAAAAAcABQAFAAUABQAFAAQABAAEAAQABAAEAAQABAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUAAAAFAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAUAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAcABwAFAAcABwAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUABwAHAAUABQAFAAUAAAAAAAcABwAAAAAABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAAAAAAAAAAABQAFAAAAAAAFAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAFAAUABQAFAAUAAAAFAAUABwAAAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABwAFAAUABQAFAAAAAAAHAAcAAAAAAAcABwAFAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAAAAAAAAAHAAcABwAAAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAUABQAFAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAHAAcABQAHAAcAAAAFAAcABwAAAAcABwAFAAUAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAFAAcABwAFAAUABQAAAAUAAAAHAAcABwAHAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAHAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUAAAAFAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAUAAAAFAAUAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABwAFAAUABQAFAAUABQAAAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABQAFAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAFAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAHAAUABQAFAAUABQAFAAUABwAHAAcABwAHAAcABwAHAAUABwAHAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABwAHAAcABwAFAAUABwAHAAcAAAAAAAAAAAAHAAcABQAHAAcABwAHAAcABwAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAUABQAFAAUABQAFAAUAAAAFAAAABQAAAAAABQAFAAUABQAFAAUABQAFAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAUABQAFAAUABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABwAFAAcABwAHAAcABwAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAUABQAFAAUABwAHAAUABQAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABQAFAAcABwAHAAUABwAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAcABQAFAAUABQAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAAAAAABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAAAAAAAAAFAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAUABQAHAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAFAAUABQAFAAcABwAFAAUABwAHAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAcABwAFAAUABwAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABQAAAAAABQAFAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAcABwAAAAAAAAAAAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAcABwAFAAcABwAAAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAFAAUABQAAAAUABQAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABwAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAHAAcABQAHAAUABQAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAAABwAHAAAAAAAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAFAAUABwAFAAcABwAFAAcABQAFAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAAAAAABwAHAAcABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAFAAcABwAFAAUABQAFAAUABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAUABQAFAAcABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABQAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAAAAAAFAAUABwAHAAcABwAFAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAHAAUABQAFAAUABQAFAAUABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAABQAAAAUABQAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAHAAcAAAAFAAUAAAAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABQAFAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAABQAFAAUABQAFAAUABQAAAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAFAAUABQAFAAUADgAOAA4ADgAOAA4ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAMAAwADAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkAAAAAAAAAAAAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAAAAAAAAAAAAsADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwACwAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAADgAOAA4AAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAAAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4AAAAOAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAAAAAAAAAAAA4AAAAOAAAAAAAAAAAADgAOAA4AAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAA="),xr=Array.isArray(vr)?function(A){for(var e=A.length,t=[],r=0;rs.x||t.y>s.y;return s=t,0===e||A});return A.body.removeChild(e),t}(document);return Object.defineProperty(Xr,"SUPPORT_WORD_BREAKING",{value:A}),A},get SUPPORT_SVG_DRAWING(){var A=function(A){var e=new Image,t=A.createElement("canvas"),A=t.getContext("2d");if(!A)return!1;e.src="data:image/svg+xml,";try{A.drawImage(e,0,0),t.toDataURL()}catch(A){return!1}return!0}(document);return Object.defineProperty(Xr,"SUPPORT_SVG_DRAWING",{value:A}),A},get SUPPORT_FOREIGNOBJECT_DRAWING(){var A="function"==typeof Array.from&&"function"==typeof window.fetch?function(t){var A=t.createElement("canvas"),r=100;A.width=r,A.height=r;var B=A.getContext("2d");if(!B)return Promise.reject(!1);B.fillStyle="rgb(0, 255, 0)",B.fillRect(0,0,r,r);var e=new Image,n=A.toDataURL();e.src=n;e=Nr(r,r,0,0,e);return B.fillStyle="red",B.fillRect(0,0,r,r),Pr(e).then(function(A){B.drawImage(A,0,0);var e=B.getImageData(0,0,r,r).data;B.fillStyle="red",B.fillRect(0,0,r,r);A=t.createElement("div");return A.style.backgroundImage="url("+n+")",A.style.height="100px",Lr(e)?Pr(Nr(r,r,0,0,A)):Promise.reject(!1)}).then(function(A){return B.drawImage(A,0,0),Lr(B.getImageData(0,0,r,r).data)}).catch(function(){return!1})}(document):Promise.resolve(!1);return Object.defineProperty(Xr,"SUPPORT_FOREIGNOBJECT_DRAWING",{value:A}),A},get SUPPORT_CORS_IMAGES(){var A=void 0!==(new Image).crossOrigin;return Object.defineProperty(Xr,"SUPPORT_CORS_IMAGES",{value:A}),A},get SUPPORT_RESPONSE_TYPE(){var A="string"==typeof(new XMLHttpRequest).responseType;return Object.defineProperty(Xr,"SUPPORT_RESPONSE_TYPE",{value:A}),A},get SUPPORT_CORS_XHR(){var A="withCredentials"in new XMLHttpRequest;return Object.defineProperty(Xr,"SUPPORT_CORS_XHR",{value:A}),A},get SUPPORT_NATIVE_TEXT_SEGMENTATION(){var A=!("undefined"==typeof Intl||!Intl.Segmenter);return Object.defineProperty(Xr,"SUPPORT_NATIVE_TEXT_SEGMENTATION",{value:A}),A}},Jr=function(A,e){this.text=A,this.bounds=e},Yr=function(A,e){var t=e.ownerDocument;if(t){var r=t.createElement("html2canvaswrapper");r.appendChild(e.cloneNode(!0));t=e.parentNode;if(t){t.replaceChild(r,e);A=f(A,r);return r.firstChild&&t.replaceChild(r.firstChild,r),A}}return d.EMPTY},Wr=function(A,e,t){var r=A.ownerDocument;if(!r)throw new Error("Node has no owner document");r=r.createRange();return r.setStart(A,e),r.setEnd(A,e+t),r},Zr=function(A){if(Xr.SUPPORT_NATIVE_TEXT_SEGMENTATION){var e=new Intl.Segmenter(void 0,{granularity:"grapheme"});return Array.from(e.segment(A)).map(function(A){return A.segment})}return function(A){for(var e,t=mr(A),r=[];!(e=t.next()).done;)e.value&&r.push(e.value.slice());return r}(A)},_r=function(A,e){return 0!==e.letterSpacing?Zr(A):function(A,e){if(Xr.SUPPORT_NATIVE_TEXT_SEGMENTATION){var t=new Intl.Segmenter(void 0,{granularity:"word"});return Array.from(t.segment(A)).map(function(A){return A.segment})}return jr(A,e)}(A,e)},qr=[32,160,4961,65792,65793,4153,4241],jr=function(A,e){for(var t,r=wA(A,{lineBreak:e.lineBreak,wordBreak:"break-word"===e.overflowWrap?"break-word":e.wordBreak}),B=[];!(t=r.next()).done;)!function(){var A,e;t.value&&(A=t.value.slice(),A=Q(A),e="",A.forEach(function(A){-1===qr.indexOf(A)?e+=g(A):(e.length&&B.push(e),B.push(g(A)),e="")}),e.length&&B.push(e))}();return B},zr=function(A,e,t){var B,n,s,o,i;this.text=$r(e.data,t.textTransform),this.textBounds=(B=A,A=this.text,s=e,A=_r(A,n=t),o=[],i=0,A.forEach(function(A){var e,t,r;n.textDecorationLine.length||0e.height?new d(e.left+(e.width-e.height)/2,e.top,e.height,e.height):e.width"),Ln(this.referenceElement.ownerDocument,t,n),o.replaceChild(o.adoptNode(this.documentElement),o.documentElement),o.close(),A},fn.prototype.createElementClone=function(A){if(Cr(A,2),zB(A))return this.createCanvasClone(A);if(MB(A))return this.createVideoClone(A);if(SB(A))return this.createStyleClone(A);var e=A.cloneNode(!1);return $B(e)&&($B(A)&&A.currentSrc&&A.currentSrc!==A.src&&(e.src=A.currentSrc,e.srcset=""),"lazy"===e.loading&&(e.loading="eager")),TB(e)?this.createCustomElementClone(e):e},fn.prototype.createCustomElementClone=function(A){var e=document.createElement("html2canvascustomelement");return Kn(A.style,e),e},fn.prototype.createStyleClone=function(A){try{var e=A.sheet;if(e&&e.cssRules){var t=[].slice.call(e.cssRules,0).reduce(function(A,e){return e&&"string"==typeof e.cssText?A+e.cssText:A},""),r=A.cloneNode(!1);return r.textContent=t,r}}catch(A){if(this.context.logger.error("Unable to access cssRules property",A),"SecurityError"!==A.name)throw A}return A.cloneNode(!1)},fn.prototype.createCanvasClone=function(e){var A;if(this.options.inlineImages&&e.ownerDocument){var t=e.ownerDocument.createElement("img");try{return t.src=e.toDataURL(),t}catch(A){this.context.logger.info("Unable to inline canvas contents, canvas is tainted",e)}}t=e.cloneNode(!1);try{t.width=e.width,t.height=e.height;var r,B,n=e.getContext("2d"),s=t.getContext("2d");return s&&(!this.options.allowTaint&&n?s.putImageData(n.getImageData(0,0,e.width,e.height),0,0):(!(r=null!==(A=e.getContext("webgl2"))&&void 0!==A?A:e.getContext("webgl"))||!1===(null==(B=r.getContextAttributes())?void 0:B.preserveDrawingBuffer)&&this.context.logger.warn("Unable to clone WebGL context as it has preserveDrawingBuffer=false",e),s.drawImage(e,0,0))),t}catch(A){this.context.logger.info("Unable to clone canvas as it is tainted",e)}return t},fn.prototype.createVideoClone=function(e){var A=e.ownerDocument.createElement("canvas");A.width=e.offsetWidth,A.height=e.offsetHeight;var t=A.getContext("2d");try{return t&&(t.drawImage(e,0,0,A.width,A.height),this.options.allowTaint||t.getImageData(0,0,A.width,A.height)),A}catch(A){this.context.logger.info("Unable to clone video as it is tainted",e)}A=e.ownerDocument.createElement("canvas");return A.width=e.offsetWidth,A.height=e.offsetHeight,A},fn.prototype.appendChildNode=function(A,e,t){XB(e)&&("SCRIPT"===e.tagName||e.hasAttribute(hn)||"function"==typeof this.options.ignoreElements&&this.options.ignoreElements(e))||this.options.copyStyles&&XB(e)&&SB(e)||A.appendChild(this.cloneNode(e,t))},fn.prototype.cloneChildNodes=function(A,e,t){for(var r,B=this,n=(A.shadowRoot||A).firstChild;n;n=n.nextSibling)XB(n)&&rn(n)&&"function"==typeof n.assignedNodes?(r=n.assignedNodes()).length&&r.forEach(function(A){return B.appendChildNode(e,A,t)}):this.appendChildNode(e,n,t)},fn.prototype.cloneNode=function(A,e){if(PB(A))return document.createTextNode(A.data);if(!A.ownerDocument)return A.cloneNode(!1);var t=A.ownerDocument.defaultView;if(t&&XB(A)&&(JB(A)||YB(A))){var r=this.createElementClone(A);r.style.transitionProperty="none";var B=t.getComputedStyle(A),n=t.getComputedStyle(A,":before"),s=t.getComputedStyle(A,":after");this.referenceElement===A&&JB(r)&&(this.clonedReferenceElement=r),jB(r)&&Mn(r);t=this.counters.parse(new Ur(this.context,B)),n=this.resolvePseudoContent(A,r,n,gn.BEFORE);TB(A)&&(e=!0),MB(A)||this.cloneChildNodes(A,r,e),n&&r.insertBefore(n,r.firstChild);s=this.resolvePseudoContent(A,r,s,gn.AFTER);return s&&r.appendChild(s),this.counters.pop(t),(B&&(this.options.copyStyles||YB(A))&&!An(A)||e)&&Kn(B,r),0===A.scrollTop&&0===A.scrollLeft||this.scrolledElements.push([r,A.scrollLeft,A.scrollTop]),(en(A)||tn(A))&&(en(r)||tn(r))&&(r.value=A.value),r}return A.cloneNode(!1)},fn.prototype.resolvePseudoContent=function(o,A,e,t){var i=this;if(e){var r=e.content,Q=A.ownerDocument;if(Q&&r&&"none"!==r&&"-moz-alt-content"!==r&&"none"!==e.display){this.counters.parse(new Ur(this.context,e));var c=new wr(this.context,e),a=Q.createElement("html2canvaspseudoelement");Kn(e,a),c.content.forEach(function(A){if(0===A.type)a.appendChild(Q.createTextNode(A.value));else if(22===A.type){var e=Q.createElement("img");e.src=A.value,e.style.opacity="1",a.appendChild(e)}else if(18===A.type){var t,r,B,n,s;"attr"===A.name?(e=A.values.filter(_A)).length&&a.appendChild(Q.createTextNode(o.getAttribute(e[0].value)||"")):"counter"===A.name?(B=(r=A.values.filter($A))[0],r=r[1],B&&_A(B)&&(t=i.counters.getCounterValue(B.value),s=r&&_A(r)?pt.parse(i.context,r.value):3,a.appendChild(Q.createTextNode(Fn(t,s,!1))))):"counters"===A.name&&(B=(t=A.values.filter($A))[0],s=t[1],r=t[2],B&&_A(B)&&(B=i.counters.getCounterValues(B.value),n=r&&_A(r)?pt.parse(i.context,r.value):3,s=s&&0===s.type?s.value:"",s=B.map(function(A){return Fn(A,n,!1)}).join(s),a.appendChild(Q.createTextNode(s))))}else if(20===A.type)switch(A.value){case"open-quote":a.appendChild(Q.createTextNode(Xt(c.quotes,i.quoteDepth++,!0)));break;case"close-quote":a.appendChild(Q.createTextNode(Xt(c.quotes,--i.quoteDepth,!1)));break;default:a.appendChild(Q.createTextNode(A.value))}}),a.className=Dn+" "+vn;t=t===gn.BEFORE?" "+Dn:" "+vn;return YB(A)?A.className.baseValue+=t:A.className+=t,a}}},fn.destroy=function(A){return!!A.parentNode&&(A.parentNode.removeChild(A),!0)},fn);function fn(A,e,t){if(this.context=A,this.options=t,this.scrolledElements=[],this.referenceElement=e,this.counters=new Bn,this.quoteDepth=0,!e.ownerDocument)throw new Error("Cloned element does not have an owner document");this.documentElement=this.cloneNode(e.ownerDocument.documentElement,!1)}(he=gn=gn||{})[he.BEFORE=0]="BEFORE",he[he.AFTER=1]="AFTER";function Hn(e){return new Promise(function(A){!e.complete&&e.src?(e.onload=A,e.onerror=A):A()})}var pn=function(A,e){var t=A.createElement("iframe");return t.className="html2canvas-container",t.style.visibility="hidden",t.style.position="fixed",t.style.left="-10000px",t.style.top="0px",t.style.border="0",t.width=e.width.toString(),t.height=e.height.toString(),t.scrolling="no",t.setAttribute(hn,"true"),A.body.appendChild(t),t},En=function(A){return Promise.all([].slice.call(A.images,0).map(Hn))},In=function(B){return new Promise(function(e,A){var t=B.contentWindow;if(!t)return A("No window assigned for iframe");var r=t.document;t.onload=B.onload=function(){t.onload=B.onload=null;var A=setInterval(function(){0"),e},Ln=function(A,e,t){A&&A.defaultView&&(e!==A.defaultView.pageXOffset||t!==A.defaultView.pageYOffset)&&A.defaultView.scrollTo(e,t)},bn=function(A){var e=A[0],t=A[1],A=A[2];e.scrollLeft=t,e.scrollTop=A},Dn="___html2canvas___pseudoelement_before",vn="___html2canvas___pseudoelement_after",xn='{\n content: "" !important;\n display: none !important;\n}',Mn=function(A){Sn(A,"."+Dn+":before"+xn+"\n ."+vn+":after"+xn)},Sn=function(A,e){var t=A.ownerDocument;t&&((t=t.createElement("style")).textContent=e,A.appendChild(t))},Tn=(Gn.getOrigin=function(A){var e=Gn._link;return e?(e.href=A,e.href=e.href,e.protocol+e.hostname+e.port):"about:blank"},Gn.isSameOrigin=function(A){return Gn.getOrigin(A)===Gn._origin},Gn.setContext=function(A){Gn._link=A.document.createElement("a"),Gn._origin=Gn.getOrigin(A.location.href)},Gn._origin="about:blank",Gn);function Gn(){}var On=(Vn.prototype.addImage=function(A){var e=Promise.resolve();return this.has(A)||(Yn(A)||Pn(A))&&(this._cache[A]=this.loadImage(A)).catch(function(){}),e},Vn.prototype.match=function(A){return this._cache[A]},Vn.prototype.loadImage=function(s){return a(this,void 0,void 0,function(){var e,r,t,B,n=this;return H(this,function(A){switch(A.label){case 0:return(e=Tn.isSameOrigin(s),r=!Xn(s)&&!0===this._options.useCORS&&Xr.SUPPORT_CORS_IMAGES&&!e,t=!Xn(s)&&!e&&!Yn(s)&&"string"==typeof this._options.proxy&&Xr.SUPPORT_CORS_XHR&&!r,e||!1!==this._options.allowTaint||Xn(s)||Yn(s)||t||r)?(B=s,t?[4,this.proxy(B)]:[3,2]):[2];case 1:B=A.sent(),A.label=2;case 2:return this.context.logger.debug("Added image "+s.substring(0,256)),[4,new Promise(function(A,e){var t=new Image;t.onload=function(){return A(t)},t.onerror=e,(Jn(B)||r)&&(t.crossOrigin="anonymous"),t.src=B,!0===t.complete&&setTimeout(function(){return A(t)},500),0t.width+C?0:Math.max(0,n-C),Math.max(0,s-l),As.TOP_RIGHT):new Zn(t.left+t.width-C,t.top+l),this.bottomRightPaddingBox=0t.width+F+A?0:n-F+A,s-(l+h),As.TOP_RIGHT):new Zn(t.left+t.width-(C+d),t.top+l+h),this.bottomRightContentBox=0A.element.container.styles.zIndex.order?(s=e,!1):0=A.element.container.styles.zIndex.order?(o=e+1,!1):0 + + + +Terminal mockup + + + + + + +
+
+
Terminal mockup
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ + + +
+

+      
+
+
+ + + +
+
+ Content + Raw ANSI or bracket markup: [b]…[/b] [cyan]…[/cyan] [muted]…[/muted] [link]…[/link] +
+ +
+ + + + +
+ + +
+ + +
+
+
+
+ + + + + diff --git a/.github/extensions/terminal-mockup/assets/styles.css b/.github/extensions/terminal-mockup/assets/styles.css new file mode 100644 index 00000000000..4ee5e213309 --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/styles.css @@ -0,0 +1,422 @@ +:root { + /* VSCode Dark+ ANSI palette */ + --vsc-bg: #1E1E1E; + --vsc-fg: #CCCCCC; + --vsc-muted: #808080; + + --ansi-black: #000000; + --ansi-red: #CD3131; + --ansi-green: #0DBC79; + --ansi-yellow: #E5E510; + --ansi-blue: #2472C8; + --ansi-magenta: #BC3FBC; + --ansi-cyan: #11A8CD; + --ansi-white: #E5E5E5; + + --ansi-br-black: #666666; + --ansi-br-red: #F14C4C; + --ansi-br-green: #23D18B; + --ansi-br-yellow: #F5F543; + --ansi-br-blue: #3B8EEA; + --ansi-br-magenta: #D670D6; + --ansi-br-cyan: #29B8DB; + --ansi-br-white: #E5E5E5; + + /* Terminal typography (overridden by data-font attribute) */ + --term-font: 'Menlo', 'Monaco', 'Courier New', monospace; + --term-fontsize: 14px; + --term-lineheight: 1.55; + --term-padding: 28px 32px; + + /* App chrome */ + --app-bg: #0e1116; + --app-panel-bg: #161b22; + --app-border: #30363d; + --app-text: #e6edf3; + --app-text-muted: #8b949e; + --app-accent: #2f81f7; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; height: 100%; } +body { + background: var(--app-bg); + color: var(--app-text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-size: 13px; +} + +.app { + display: grid; + grid-template-rows: auto auto 1fr 6px var(--editor-height, 240px); + grid-template-areas: + "topbar" + "toolbar" + "preview" + "handle" + "editor"; + height: 100vh; + min-height: 0; +} +.topbar { grid-area: topbar; } +.toolbar { grid-area: toolbar; } +.preview-pane { grid-area: preview; } +.resize-handle { grid-area: handle; } +.editor-pane { grid-area: editor; } + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 16px; + background: var(--app-panel-bg); + border-bottom: 1px solid var(--app-border); +} +.topbar .title { + font-weight: 600; + font-size: 13px; + letter-spacing: 0.2px; + color: var(--app-text); +} + +/* Resize handle */ +.resize-handle { + background: var(--app-border); + cursor: row-resize; + position: relative; + transition: background 120ms ease; +} +.resize-handle:hover, +.resize-handle.dragging { + background: var(--app-accent); +} +.resize-handle::before { + content: ""; + position: absolute; + inset: -3px 0; +} +.resize-handle:focus-visible { + outline: 2px solid var(--app-accent); + outline-offset: -2px; +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--app-panel-bg); + border-bottom: 1px solid var(--app-border); + flex-wrap: wrap; +} +.toolbar .controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.ctl { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--app-text-muted); +} +.ctl > span { white-space: nowrap; } +.ctl select, .ctl input[type="range"] { + background: #0d1117; + color: var(--app-text); + border: 1px solid var(--app-border); + border-radius: 6px; + padding: 4px 6px; + font: inherit; +} +.ctl input[type="range"] { padding: 0; } +.ctl.checkbox { gap: 6px; cursor: pointer; user-select: none; } +.ctl output { font-variant-numeric: tabular-nums; min-width: 4ch; text-align: right; color: var(--app-text); } + +button { + background: #21262d; + color: var(--app-text); + border: 1px solid var(--app-border); + border-radius: 6px; + padding: 6px 12px; + font: inherit; + cursor: pointer; +} +button:hover { background: #2d333b; } +button.primary { background: var(--app-accent); border-color: var(--app-accent); color: white; } +button.primary:hover { background: #1f6feb; } +button:disabled { opacity: 0.5; cursor: not-allowed; } +.ctl-sep { + width: 1px; + align-self: stretch; + background: var(--app-border); + margin: 0 2px; +} + +/* Preview pane */ +.preview-pane { + position: relative; + display: flex; + align-items: safe center; + justify-content: safe center; + overflow: auto; + padding: 32px; + min-height: 0; + background: + radial-gradient(circle at 50% 0%, #1a2138 0%, #0e1116 60%); +} + +/* Pane toggles live inside the topbar so they stay visible even when the toolbar is hidden. */ +.pane-toggles { + display: flex; + gap: 6px; +} +.pane-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 11px; + font-weight: 500; + color: var(--app-text); + background: #21262d; + border: 1px solid var(--app-border); + border-radius: 6px; + cursor: pointer; +} +.pane-toggle:hover { + border-color: var(--app-accent); + background: #2d333b; +} +.pane-toggle-icon { + font-size: 10px; + line-height: 1; + opacity: 0.9; +} +.app.toolbar-collapsed > .toolbar { display: none !important; } +.app.editor-collapsed > .resize-handle, +.app.editor-collapsed > .editor-pane { display: none !important; } +.app.toolbar-collapsed { grid-template-rows: auto 0 1fr 6px var(--editor-height, 240px); } +.app.editor-collapsed { grid-template-rows: auto auto 1fr 0 0; } +.app.toolbar-collapsed.editor-collapsed { grid-template-rows: auto 0 1fr 0 0; } + +/* The mockup root is what gets captured to PNG */ +.mockup { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 56px 64px; + border-radius: 8px; +} +.grid-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + display: none; +} +.mockup.backdrop-grid .grid-svg { display: block; } +.mockup .window { position: relative; z-index: 1; } +.mockup.backdrop-none { + background: transparent; + padding: 0; +} +.mockup.backdrop-solid { + background: #0a0d14; +} +.mockup.backdrop-grid { + background: + radial-gradient(ellipse 80% 60% at 50% -15%, rgba(80,150,255,0.55) 0%, rgba(80,150,255,0) 60%), + linear-gradient(180deg, #0a1330 0%, #04060c 80%); +} + +/* Terminal window */ +.window { + width: var(--mockup-width, 800px); + background: var(--vsc-bg); + border-radius: 12px; + overflow: hidden; + box-shadow: + 0 1px 0 rgba(255,255,255,0.04) inset, + 0 0 0 1px rgba(255,255,255,0.06), + 0 30px 80px rgba(0,0,0,0.55), + 0 12px 24px rgba(0,0,0,0.35); +} +.window.no-chrome .titlebar { display: none; } +.window.no-chrome { border-radius: 8px; } + +.titlebar { + height: 36px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); + border-bottom: 1px solid rgba(0,0,0,0.4); +} +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: #4a4a4a; +} +.dot.red, .dot.yellow, .dot.green { background: #4a4a4a; } + +.terminal { + margin: 0; + padding: var(--term-padding); + background: var(--vsc-bg); + color: var(--vsc-fg); + font-family: var(--term-font); + font-size: var(--term-fontsize); + line-height: var(--term-lineheight); + white-space: pre-wrap; + word-break: break-word; + font-variant-ligatures: none; +} +.window.body-gradient .terminal { + background: linear-gradient(180deg, #2a2a2a 0%, #1e1e1e 30%, #1a1a1a 100%); +} + +/* Style classes emitted by the parser */ +.fg-black { color: var(--ansi-black); } +.fg-red { color: var(--ansi-red); } +.fg-green { color: var(--ansi-green); } +.fg-yellow { color: var(--ansi-yellow); } +.fg-blue { color: var(--ansi-blue); } +.fg-magenta { color: var(--ansi-magenta); } +.fg-cyan { color: var(--ansi-cyan); } +.fg-white { color: var(--ansi-white); } +.fg-br-black { color: var(--ansi-br-black); } +.fg-br-red { color: var(--ansi-br-red); } +.fg-br-green { color: var(--ansi-br-green); } +.fg-br-yellow { color: var(--ansi-br-yellow); } +.fg-br-blue { color: var(--ansi-br-blue); } +.fg-br-magenta { color: var(--ansi-br-magenta); } +.fg-br-cyan { color: var(--ansi-br-cyan); } +.fg-br-white { color: var(--ansi-br-white); } +.fg-muted { color: var(--vsc-muted); } +.bold { font-weight: 700; } +.italic { font-style: italic; } +.underline { text-decoration: underline; } +.dim { opacity: 0.55; } + +/* Editor */ +.editor-pane { + display: grid; + grid-template-rows: auto 1fr; + border-top: 1px solid var(--app-border); + background: var(--app-panel-bg); + min-height: 0; + overflow: hidden; +} +.editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + font-size: 12px; + color: var(--app-text-muted); + border-bottom: 1px solid var(--app-border); +} +.editor-header .hint code { + font-family: var(--term-font); + background: #0d1117; + border: 1px solid var(--app-border); + border-radius: 4px; + padding: 1px 5px; + margin: 0 2px; + color: var(--app-text); +} +#editor { + width: 100%; + height: 100%; + border: none; + background: #0d1117; + color: var(--app-text); + padding: 12px 16px; + font-family: var(--term-font); + font-size: 13px; + line-height: 1.5; + resize: none; + outline: none; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: #21262d; + color: var(--app-text); + border: 1px solid var(--app-border); + padding: 8px 14px; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + font-size: 12px; + z-index: 1000; +} + +/* Font dropdown effective values */ +.window[data-font="menlo"] .terminal { font-family: 'Menlo', 'Monaco', 'Courier New', monospace; } +.window[data-font="sfmono"] .terminal { font-family: 'SF Mono', 'SFMono-Regular', ui-monospace, Menlo, monospace; } +.window[data-font="cascadia"] .terminal { font-family: 'Cascadia Code', 'Cascadia Mono', Consolas, monospace; } +.window[data-font="jetbrains"] .terminal { font-family: 'JetBrains Mono', monospace; } +.window[data-font="fira"] .terminal { font-family: 'Fira Code', monospace; } +.window[data-font="source"] .terminal { font-family: 'Source Code Pro', monospace; } +.window[data-font="roboto"] .terminal { font-family: 'Roboto Mono', monospace; } +.window[data-font="consolas"] .terminal { font-family: 'Consolas', 'Liberation Mono', monospace; } + +/* Save-as dialog */ +.save-dialog { + border: 1px solid var(--app-border); + background: #161b22; + color: var(--app-text); + border-radius: 10px; + padding: 0; + box-shadow: 0 24px 56px rgba(0,0,0,0.6); + max-width: 420px; + width: calc(100% - 48px); +} +.save-dialog::backdrop { + background: rgba(0,0,0,0.55); +} +.save-dialog form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px 20px 16px; +} +.save-dialog label { + font-size: 12px; + color: var(--app-text-muted); + letter-spacing: 0.02em; + text-transform: uppercase; +} +.save-dialog input[type="text"] { + background: #0d1117; + border: 1px solid var(--app-border); + border-radius: 6px; + color: var(--app-text); + padding: 8px 10px; + font: inherit; + font-size: 13px; + outline: none; +} +.save-dialog input[type="text"]:focus { + border-color: #2f81f7; + box-shadow: 0 0 0 2px rgba(47,129,247,0.35); +} +.save-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/.github/extensions/terminal-mockup/extension.mjs b/.github/extensions/terminal-mockup/extension.mjs new file mode 100644 index 00000000000..18aeb44a4da --- /dev/null +++ b/.github/extensions/terminal-mockup/extension.mjs @@ -0,0 +1,570 @@ +// Extension: terminal-mockup +// Generate VSCode-style terminal screenshot mockups with dummy data +// for marketing materials. The canvas renders inside an iframe served +// by a loopback HTTP server; all editing, theming, and PNG export +// happen client-side in the iframe app. + +import { createServer } from "node:http"; +import { lstat, mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { dirname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ASSETS_DIR = join(__dirname, "assets"); +const PROJECT_DIR = join(__dirname, "library"); + +const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); +const USER_DIR = join(COPILOT_HOME, "extensions", "terminal-mockup", "artifacts"); + +const SCOPES = ["project", "user"]; +const SCOPE_DIRS = { project: PROJECT_DIR, user: USER_DIR }; +function isScope(s) { return s === "project" || s === "user"; } + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; + +const instances = new Map(); + +function ensureInstanceState(instanceId) { + let state = instances.get(instanceId); + if (!state) { + state = { + content: "", + options: {}, + sse: new Set(), + }; + instances.set(instanceId, state); + } + return state; +} + +function sendSse(state, res, payload) { + if (res.destroyed || res.writableEnded) { + state.sse.delete(res); + return false; + } + try { + res.write(`data: ${payload}\n\n`); + return true; + } catch { + state.sse.delete(res); + return false; + } +} + +function pushUpdate(instanceId) { + const state = instances.get(instanceId); + if (!state) return; + const payload = JSON.stringify({ type: "state", content: state.content, options: state.options }); + for (const res of state.sse) sendSse(state, res, payload); +} + +function broadcastLibraryChanged(payload = {}) { + const event = JSON.stringify({ type: "library_changed", ...payload }); + for (const state of instances.values()) { + for (const res of state.sse) sendSse(state, res, event); + } +} + +function slugify(name) { + return String(name || "") + .toLowerCase() + .normalize("NFKD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .slice(0, 80); +} + +function isValidSlug(s) { + return typeof s === "string" && /^[a-z0-9][a-z0-9-]{0,79}$/.test(s); +} + +async function ensureDir(scope) { + await mkdir(SCOPE_DIRS[scope], { recursive: true }); +} + +async function listScope(scope) { + try { + await ensureDir(scope); + const entries = await readdir(SCOPE_DIRS[scope]); + const out = []; + for (const e of entries) { + if (!e.endsWith(".json")) continue; + const slug = e.slice(0, -5); + try { + const raw = await readFile(join(SCOPE_DIRS[scope], e), "utf8"); + const doc = JSON.parse(raw); + out.push({ scope, slug, name: doc.name || slug, savedAt: doc.savedAt }); + } catch { + out.push({ scope, slug, name: slug }); + } + } + return out; + } catch { + return []; + } +} + +async function listMockups() { + const [projectItems, userItems] = await Promise.all([listScope("project"), listScope("user")]); + const out = [...projectItems, ...userItems]; + out.sort((a, b) => { + if (a.scope !== b.scope) return a.scope === "project" ? -1 : 1; + return (a.name || "").localeCompare(b.name || ""); + }); + return out; +} + +async function readMockup(slug, scope) { + if (!isValidSlug(slug)) return null; + const order = scope && isScope(scope) ? [scope] : SCOPES; + for (const sc of order) { + try { + const raw = await readFile(join(SCOPE_DIRS[sc], `${slug}.json`), "utf8"); + const doc = JSON.parse(raw); + return { ...doc, scope: sc, slug }; + } catch {} + } + return null; +} + +async function refuseSymlink(path) { + try { + const stat = await lstat(path); + if (stat.isSymbolicLink()) { + throw new CanvasError("refused_symlink", `Refusing to operate on symlink: ${path}`); + } + } catch (err) { + if (err && err.code === "ENOENT") return; + throw err; + } +} + +async function writeMockup(slug, doc, scope) { + if (!isValidSlug(slug)) throw new Error("invalid slug"); + if (!isScope(scope)) throw new Error("invalid scope"); + await ensureDir(scope); + const target = join(SCOPE_DIRS[scope], `${slug}.json`); + await refuseSymlink(target); + await writeFile(target, JSON.stringify(doc, null, 2) + "\n", "utf8"); +} + +async function deleteMockup(slug, scope) { + if (!isValidSlug(slug)) return false; + if (!isScope(scope)) return false; + const target = join(SCOPE_DIRS[scope], `${slug}.json`); + try { + await refuseSymlink(target); + await unlink(target); + return true; + } catch { + return false; + } +} + +async function readJsonBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let bytes = 0; + req.on("data", (chunk) => { + bytes += chunk.length; + if (bytes > 5_000_000) { + reject(new Error("body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (bytes === 0) return resolve({}); + const body = Buffer.concat(chunks).toString("utf8"); + try { resolve(JSON.parse(body)); } catch (e) { reject(e); } + }); + req.on("error", reject); + }); +} + +async function serveStatic(req, res) { + const url = new URL(req.url, "http://127.0.0.1"); + let path = decodeURIComponent(url.pathname); + if (path === "/" || path === "") path = "/index.html"; + const safe = normalize(path).replace(/^[/\\]+/, ""); + const filePath = join(ASSETS_DIR, safe); + if (!filePath.startsWith(ASSETS_DIR)) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + try { + const data = await readFile(filePath); + const ext = filePath.slice(filePath.lastIndexOf(".")); + res.setHeader("Content-Type", MIME[ext] || "application/octet-stream"); + res.setHeader("Cache-Control", "no-store"); + res.end(data); + } catch (err) { + res.statusCode = 404; + res.end("Not found"); + } +} + +function jsonResponse(res, status, body) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(body)); +} + +async function handleMockupsApi(req, res, urlPath, instanceId) { + // Routes: + // GET /mockups → list merged + // POST /mockups/ → create by name (server slugifies) + // GET /mockups// → read specific scope + // PUT /mockups// → write specific scope + // DELETE /mockups// → delete specific scope + // GET /mockups/ → read (search project then user, back-compat) + const method = req.method || "GET"; + const parts = urlPath.replace(/^\/mockups\/?/, "").split("/").filter(Boolean); + + try { + if (parts.length === 0 && method === "GET") { + return jsonResponse(res, 200, { items: await listMockups() }); + } + if (parts.length === 1 && method === "POST" && isScope(parts[0])) { + const scope = parts[0]; + const body = await readJsonBody(req); + const slugified = slugify(body.name || body.slug || ""); + if (!isValidSlug(slugified)) return jsonResponse(res, 400, { error: "invalid_name" }); + const doc = { + name: typeof body.name === "string" ? body.name : slugified, + savedAt: new Date().toISOString(), + content: typeof body.content === "string" ? body.content : "", + options: body.options && typeof body.options === "object" ? body.options : {}, + }; + await writeMockup(slugified, doc, scope); + return jsonResponse(res, 200, { ok: true, scope, slug: slugified, doc }); + } + if (parts.length === 2 && isScope(parts[0])) { + const [scope, slug] = parts; + if (method === "GET") { + const doc = await readMockup(slug, scope); + if (!doc) return jsonResponse(res, 404, { error: "not_found" }); + return jsonResponse(res, 200, doc); + } + if (method === "PUT") { + const body = await readJsonBody(req); + if (!isValidSlug(slug)) return jsonResponse(res, 400, { error: "invalid_slug" }); + const doc = { + name: typeof body.name === "string" ? body.name : slug, + savedAt: new Date().toISOString(), + content: typeof body.content === "string" ? body.content : "", + options: body.options && typeof body.options === "object" ? body.options : {}, + }; + await writeMockup(slug, doc, scope); + return jsonResponse(res, 200, { ok: true, scope, slug, doc }); + } + if (method === "DELETE") { + const ok = await deleteMockup(slug, scope); + return jsonResponse(res, ok ? 200 : 404, { ok }); + } + } + if (parts.length === 1 && method === "GET") { + // back-compat: GET /mockups/, search both scopes + const doc = await readMockup(parts[0]); + if (!doc) return jsonResponse(res, 404, { error: "not_found" }); + return jsonResponse(res, 200, doc); + } + return jsonResponse(res, 405, { error: "method_not_allowed" }); + } catch (err) { + return jsonResponse(res, 500, { error: "server_error", message: String(err.message || err) }); + } +} + +async function startServer(instanceId) { + const state = ensureInstanceState(instanceId); + let port = 0; + const server = createServer((req, res) => { + // Defense against DNS rebinding and same-port cross-origin loopback requests: + // reject any request whose Host header does not match the loopback bound port, + // or whose Origin (if present) is not loopback. Bound to 127.0.0.1, so the + // only way to reach here with a foreign Host is a rebound DNS name. + const host = req.headers.host || ""; + if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + const origin = req.headers.origin; + if (origin && !origin.startsWith("http://127.0.0.1:") && !origin.startsWith("http://localhost:")) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + const url = new URL(req.url, "http://127.0.0.1"); + if (url.pathname === "/state") { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify({ content: state.content, options: state.options })); + return; + } + if (url.pathname === "/events") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Connection", "keep-alive"); + state.sse.add(res); + req.on("close", () => { + state.sse.delete(res); + }); + sendSse(state, res, JSON.stringify({ type: "state", content: state.content, options: state.options })); + return; + } + if (url.pathname === "/mockups" || url.pathname.startsWith("/mockups/")) { + handleMockupsApi(req, res, url.pathname, instanceId).catch((err) => { + jsonResponse(res, 500, { error: "server_error", message: String(err.message || err) }); + }); + return; + } + serveStatic(req, res).catch(() => { + res.statusCode = 500; + res.end("Server error"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + port = typeof address === "object" && address ? address.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +await ensureDir("project").catch(() => {}); + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "terminal-mockup", + displayName: "Terminal mockup", + description: "Render dummy gh CLI output as a VSCode-styled terminal screenshot for marketing materials. Accepts raw ANSI or bracket markup. Supports a per-user saved-mockups library for managing multiple mockups in parallel.", + inputSchema: { + type: "object", + properties: { + content: { type: "string", description: "Initial terminal content. Supports raw ANSI escape codes and bracket markup like [b]...[/b], [cyan]...[/cyan]." }, + options: { type: "object", description: "Initial render options (chrome, backdrop, font, width)." }, + loadSlug: { type: "string", description: "If set, load this saved mockup by slug on open." }, + loadScope: { type: "string", enum: ["user", "project"], description: "Scope for loadSlug. If omitted, project is searched first then user." }, + }, + }, + actions: [ + { + name: "set_content", + description: "Replace the terminal content shown in the canvas. Supports ANSI escape codes and bracket markup.", + inputSchema: { + type: "object", + required: ["text"], + properties: { + text: { type: "string" }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const text = ctx.input && typeof ctx.input.text === "string" ? ctx.input.text : ""; + state.content = text; + pushUpdate(ctx.instanceId); + return { ok: true, length: text.length }; + }, + }, + { + name: "set_options", + description: "Adjust rendering options: chrome (none|macos), backdrop (none|solid|grid), font, fontSize, width, bodyGradient, autoStyle.", + inputSchema: { + type: "object", + properties: { + chrome: { type: "string", enum: ["none", "macos"] }, + backdrop: { type: "string", enum: ["none", "solid", "grid"] }, + font: { type: "string" }, + fontSize: { type: "number" }, + width: { type: "number" }, + bodyGradient: { type: "boolean" }, + autoStyle: { type: "boolean" }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + state.options = { ...state.options, ...(ctx.input || {}) }; + pushUpdate(ctx.instanceId); + return { ok: true, options: state.options }; + }, + }, + { + name: "save_mockup", + description: "Save the current canvas content and options to a library. scope=\"user\" (default) writes to the per-user library; scope=\"project\" writes into the extension's committed library folder.", + inputSchema: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", description: "Human-readable name. Slug is derived from this." }, + slug: { type: "string", description: "Optional explicit slug. Must match [a-z0-9-]+." }, + scope: { type: "string", enum: ["user", "project"], description: "Where to write. Defaults to user." }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const name = ctx.input && typeof ctx.input.name === "string" ? ctx.input.name : ""; + const explicit = ctx.input && typeof ctx.input.slug === "string" ? ctx.input.slug : null; + const scope = isScope(ctx.input?.scope) ? ctx.input.scope : "user"; + const slug = explicit && isValidSlug(explicit) ? explicit : slugify(name); + if (!isValidSlug(slug)) throw new CanvasError("invalid_name", "Name must contain at least one alphanumeric character"); + const doc = { + name: name || slug, + savedAt: new Date().toISOString(), + content: state.content, + options: state.options, + }; + try { + await writeMockup(slug, doc, scope); + } catch (err) { + throw new CanvasError("save_failed", String(err.message || err)); + } + broadcastLibraryChanged({ action: "saved", scope, slug, name: doc.name }); + return { ok: true, scope, slug, name: doc.name }; + }, + }, + { + name: "load_mockup", + description: "Load a saved mockup by slug and apply its content + options to the canvas. If scope is omitted, the project library is searched first, then the user library.", + inputSchema: { + type: "object", + required: ["slug"], + properties: { + slug: { type: "string" }, + scope: { type: "string", enum: ["user", "project"] }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const slug = ctx.input && typeof ctx.input.slug === "string" ? ctx.input.slug : ""; + const scope = isScope(ctx.input?.scope) ? ctx.input.scope : undefined; + const doc = await readMockup(slug, scope); + if (!doc) throw new CanvasError("not_found", `No saved mockup with slug "${slug}"`); + state.content = typeof doc.content === "string" ? doc.content : ""; + if (doc.options && typeof doc.options === "object") { + state.options = { ...state.options, ...doc.options }; + } + pushUpdate(ctx.instanceId); + return { ok: true, scope: doc.scope, slug, name: doc.name }; + }, + }, + { + name: "list_mockups", + description: "List all saved mockups from both the project (committed) library and the per-user library. Each item includes its scope.", + handler: async () => ({ items: await listMockups() }), + }, + { + name: "delete_mockup", + description: "Delete a saved mockup by slug from the given scope.", + inputSchema: { + type: "object", + required: ["slug", "scope"], + properties: { + slug: { type: "string" }, + scope: { type: "string", enum: ["user", "project"] }, + }, + }, + handler: async (ctx) => { + const slug = ctx.input && typeof ctx.input.slug === "string" ? ctx.input.slug : ""; + const scope = isScope(ctx.input?.scope) ? ctx.input.scope : "user"; + const ok = await deleteMockup(slug, scope); + if (ok) broadcastLibraryChanged({ action: "deleted", scope, slug }); + return { ok }; + }, + }, + { + name: "batch_export", + description: "Tell the open iframe to download a PNG (or JPG) for each named saved mockup. All exports render with the iframe's current toolbar options (chrome, backdrop, font, etc.), NOT each mockup's saved options. Each item is either a bare slug (defaults to searching project then user) or a scoped string like \"project:my-slug\" / \"user:my-slug\". Filenames are `.`.", + inputSchema: { + type: "object", + required: ["slugs"], + properties: { + slugs: { type: "array", items: { type: "string" }, description: "Bare slug or \":\"." }, + suffix: { type: "string", description: "Suffix appended to slug before the extension (e.g. \"-no-frame\")." }, + format: { type: "string", enum: ["png", "jpg"], description: "Optional. Defaults to whatever the toolbar has selected." }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const slugs = Array.isArray(ctx.input?.slugs) ? ctx.input.slugs.filter((s) => typeof s === "string") : []; + if (slugs.length === 0) throw new CanvasError("no_slugs", "Provide at least one slug to export"); + if (state.sse.size === 0) { + throw new CanvasError("iframe_not_connected", "No iframe is connected to receive the export request. Open the canvas first."); + } + const suffix = typeof ctx.input?.suffix === "string" ? ctx.input.suffix : ""; + const format = ctx.input?.format === "jpg" ? "jpg" : (ctx.input?.format === "png" ? "png" : null); + const payload = JSON.stringify({ type: "batch_export", slugs, suffix, format }); + let delivered = 0; + for (const res of state.sse) { + if (sendSse(state, res, payload)) delivered++; + } + if (delivered === 0) { + throw new CanvasError("iframe_not_connected", "All iframe connections were stale; no exports were dispatched."); + } + return { ok: true, count: slugs.length, delivered }; + }, + }, + ], + open: async (ctx) => { + const state = ensureInstanceState(ctx.instanceId); + if (ctx.input && typeof ctx.input === "object") { + if (typeof ctx.input.loadSlug === "string") { + const loadScope = isScope(ctx.input.loadScope) ? ctx.input.loadScope : undefined; + const doc = await readMockup(ctx.input.loadSlug, loadScope); + if (doc) { + state.content = typeof doc.content === "string" ? doc.content : state.content; + if (doc.options && typeof doc.options === "object") { + state.options = { ...state.options, ...doc.options }; + } + } + } + if (typeof ctx.input.content === "string") state.content = ctx.input.content; + if (ctx.input.options && typeof ctx.input.options === "object") { + state.options = { ...state.options, ...ctx.input.options }; + } + } + let entry = state.server; + if (!entry) { + entry = await startServer(ctx.instanceId); + state.server = entry; + } + pushUpdate(ctx.instanceId); + return { title: "Terminal mockup", url: entry.url }; + }, + onClose: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) return; + for (const res of state.sse) { + try { res.end(); } catch {} + } + state.sse.clear(); + if (state.server) { + state.server.server.closeAllConnections?.(); + await new Promise((resolve) => state.server.server.close(() => resolve())); + } + instances.delete(ctx.instanceId); + }, + }), + ], +}); diff --git a/.github/extensions/terminal-mockup/library/discussion-list-monas-cafe.json b/.github/extensions/terminal-mockup/library/discussion-list-monas-cafe.json new file mode 100644 index 00000000000..4841e466bb0 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/discussion-list-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Discussion list - Mona's Cafe", + "savedAt": "2026-06-06T19:28:53.327Z", + "content": "[muted]$[/muted] [b]gh discussion list --repo monalisa/monas-cafe --limit 6[/b]\n\nShowing 6 of 87 open discussions in [b]monalisa/monas-cafe[/b]\n\n[dim][u]ID [/u] [u]TITLE [/u] [u]CATEGORY [/u] [u]LABELS [/u] [u]ANSWERED[/u] [u]UPDATED [/u][/dim]\n[brgreen]#87[/brgreen] Sign-in flow desig... Q&A [brblue]Enhancement[/brblue] ✓ [muted]about 2 days ago[/muted]\n[brgreen]#82[/brgreen] Show and tell: lat... Show and tell [muted]about 4 days ago[/muted]\n[brgreen]#78[/brgreen] Custom CSS hooks f... Ideas [brblue]Enhancement[/brblue] [muted]about 1 week ago[/muted]\n[brgreen]#71[/brgreen] Roadmap for Mona's... Announcements [muted]about 2 weeks ago[/muted]\n[brgreen]#64[/brgreen] Failing on Apple S... Q&A [brred]Bug[/brred] ✓ [muted]about 3 weeks ago[/muted]\n[brgreen]#55[/brgreen] Welcome new contri... General [muted]about 1 month ago[/muted]\n[muted]And 81 more[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/discussion-view-monas-cafe.json b/.github/extensions/terminal-mockup/library/discussion-view-monas-cafe.json new file mode 100644 index 00000000000..4f5ff805bb9 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/discussion-view-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Discussion view - Mona's Cafe", + "savedAt": "2026-06-06T19:21:43.055Z", + "content": "[muted]$[/muted] [b]gh discussion view 87 --repo monalisa/monas-cafe[/b]\n[b]Sign-in flow design feedback[/b] [brblue]#87[/brblue]\n[brgreen]Open[/brgreen] [muted]·[/muted] Q&A [muted]·[/muted] Asked by Mona [muted]·[/muted] about 2 days ago [muted]·[/muted] 6 comments\n\n Finalizing the sign-in flow for [b]Mona's Cafe[/b] v2 and would love\n community feedback on the OAuth callback design and error states.\n\n\n[muted]View this discussion on GitHub: https://github.com/monalisa/monas-cafe/discussions/87[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/issue-create-monas-cafe.json b/.github/extensions/terminal-mockup/library/issue-create-monas-cafe.json new file mode 100644 index 00000000000..127fbebde5f --- /dev/null +++ b/.github/extensions/terminal-mockup/library/issue-create-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Issue create - Mona's Cafe", + "savedAt": "2026-06-06T19:21:43.055Z", + "content": "[muted]$[/muted] [b]gh issue create \\[/b]\n [b]--title \"Recalibrate coffee brewing algorithm\" \\[/b]\n [b]--type Task \\[/b]\n [b]--parent 119 \\[/b]\n [b]--blocked-by 134 \\[/b]\n [b]--blocking 152[/b]\n\nCreating issue in monalisa/monas-cafe\n\n[muted]https://github.com/monalisa/monas-cafe/issues/156[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/issue-view-json-monas-cafe.json b/.github/extensions/terminal-mockup/library/issue-view-json-monas-cafe.json new file mode 100644 index 00000000000..ae27dee6053 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/issue-view-json-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Issue view --json - Mona's Cafe", + "savedAt": "2026-06-06T20:00:00.000Z", + "content": "[muted]$[/muted] [b]gh issue view 142 --repo monalisa/monas-cafe \\[/b]\n [b]--json number,title,state,issueType,parent,\\[/b]\n [b]subIssuesSummary,blockedBy,blocking[/b]\n[b][white]{[/white][/b]\n [b][blue]\"blockedBy\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"nodes\"[/blue][/b][b][white]:[/white][/b] [b][white][[/white][/b]\n [b][white]{[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 128[b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Provision staging OAuth app credentials\"[/green][b][white],[/white][/b]\n [b][blue]\"url\"[/blue][/b][b][white]:[/white][/b] [green]\"https://github.com/monalisa/monas-cafe/issues/128\"[/green]\n [b][white]}[/white][/b]\n [b][white]][/white][/b][b][white],[/white][/b]\n [b][blue]\"totalCount\"[/blue][/b][b][white]:[/white][/b] 1\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"blocking\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"nodes\"[/blue][/b][b][white]:[/white][/b] [b][white][[/white][/b]\n [b][white]{[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 161[b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Enable per-user order history sync\"[/green][b][white],[/white][/b]\n [b][blue]\"url\"[/blue][/b][b][white]:[/white][/b] [green]\"https://github.com/monalisa/monas-cafe/issues/161\"[/green]\n [b][white]}[/white][/b]\n [b][white]][/white][/b][b][white],[/white][/b]\n [b][blue]\"totalCount\"[/blue][/b][b][white]:[/white][/b] 1\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"issueType\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"color\"[/blue][/b][b][white]:[/white][/b] [green]\"BLUE\"[/green][b][white],[/white][/b]\n [b][blue]\"description\"[/blue][/b][b][white]:[/white][/b] [green]\"New capability or enhancement\"[/green][b][white],[/white][/b]\n [b][blue]\"id\"[/blue][/b][b][white]:[/white][/b] [green]\"IT_example_feature_type_id\"[/green][b][white],[/white][/b]\n [b][blue]\"name\"[/blue][/b][b][white]:[/white][/b] [green]\"Feature\"[/green]\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 142[b][white],[/white][/b]\n [b][blue]\"parent\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 119[b][white],[/white][/b]\n [b][blue]\"repository\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"nameWithOwner\"[/blue][/b][b][white]:[/white][/b] [green]\"monalisa/monas-cafe\"[/green]\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Mona's Cafe v2 launch\"[/green][b][white],[/white][/b]\n [b][blue]\"url\"[/blue][/b][b][white]:[/white][/b] [green]\"https://github.com/monalisa/monas-cafe/issues/119\"[/green]\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"subIssuesSummary\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"completed\"[/blue][/b][b][white]:[/white][/b] 1[b][white],[/white][/b]\n [b][blue]\"percentCompleted\"[/blue][/b][b][white]:[/white][/b] 50[b][white],[/white][/b]\n [b][blue]\"total\"[/blue][/b][b][white]:[/white][/b] 2\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Ship GitHub sign-in for Mona's Cafe v2\"[/green]\n[b][white]}[/white][/b]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": false + } +} diff --git a/.github/extensions/terminal-mockup/library/issue-view-monas-cafe.json b/.github/extensions/terminal-mockup/library/issue-view-monas-cafe.json new file mode 100644 index 00000000000..d89b86325c5 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/issue-view-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Issue view - Mona's Cafe", + "savedAt": "2026-06-06T19:23:22.273Z", + "content": "[muted]$[/muted] [b]gh issue view 142[/b]\n[b]Ship GitHub sign-in for Mona's Cafe v2[/b] monalisa/monas-cafe#142\n[brgreen]Open[/brgreen] [muted]•[/muted] monalisa (Mona Lisa) opened about 2 hours ago [muted]•[/muted] 4 comments\n[b]Blocked by:[/b] monalisa/monas-cafe#128 Provision staging OAuth app credentials\n[b]Blocking:[/b] monalisa/monas-cafe#161 Enable per-user order history sync\n\n Let people sign in to [b]Mona's Cafe[/b] with their GitHub account 🚀\n\n\n[b]Sub-issues[/b] [muted]·[/muted] 1/2 (50%)\n[magenta]Closed[/magenta] monalisa/monas-cafe#137 Implement OAuth callback handler\n[brgreen]Open[/brgreen] monalisa/monas-cafe#145 Add sign-in button to landing page\n\n[muted]View this issue on GitHub: https://github.com/monalisa/monas-cafe/issues/142[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-issue-list.json b/.github/extensions/terminal-mockup/library/sample-issue-list.json new file mode 100644 index 00000000000..1401e619814 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-issue-list.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh issue list", + "savedAt": "2026-06-06T19:17:45.740Z", + "content": "[muted]$[/muted] [b]gh issue list --label bug[/b]\n\nShowing 3 of 3 issues in [b]monalisa/my-project[/b] that match the search query\n\n[brgreen]#214[/brgreen] [b]Crash when token expires during long-running request[/b] [muted]bug, priority:high[/muted] 2h\n[brgreen]#198[/brgreen] [b]Incorrect error message on rate limit[/b] [muted]bug[/muted] 1d\n[brgreen]#191[/brgreen] [b]README example fails on Windows[/b] [muted]bug, docs[/muted] 3d", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-pr-list.json b/.github/extensions/terminal-mockup/library/sample-pr-list.json new file mode 100644 index 00000000000..1a6253c5990 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-pr-list.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh pr list", + "savedAt": "2026-06-06T19:17:45.739Z", + "content": "[muted]$[/muted] [b]gh pr list[/b]\n\nShowing 4 of 4 open pull requests in [b]monalisa/my-project[/b]\n\n[brgreen]#142[/brgreen] [b]Add support for OIDC tokens[/b] feature/oidc-tokens about 1 hour ago\n[brgreen]#138[/brgreen] [b]Fix race condition in token refresh[/b] fix/token-race about 3 hours ago\n[brgreen]#135[/brgreen] [b]Bump dependencies to latest[/b] chore/bump-deps yesterday\n[brgreen]#129[/brgreen] [b]Refactor http client error handling[/b] refactor/http-errors 2 days ago", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-pr-view-comments.json b/.github/extensions/terminal-mockup/library/sample-pr-view-comments.json new file mode 100644 index 00000000000..a0155b7e6ed --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-pr-view-comments.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh pr view --comments", + "savedAt": "2026-06-06T19:17:45.738Z", + "content": "[muted]$[/muted] [b]gh pr edit --add-reviewer @copilot[/b]\nhttps://github.com/monalisa/my-project/pull/111\n\n[muted]...[/muted]\n\n[muted]$[/muted] [b]gh pr view --comments[/b]\n[b]Add new feature[/b] [muted]monalisa/my-project#111[/muted]\n[muted]Draft[/muted] • Copilot (AI) wants to merge 2 commits into main from feature-branch • [muted]about 2 hours ago[/muted]\n[brgreen]+47[/brgreen] [brred]-0[/brred] • [muted]No checks[/muted]\n[b]Reviewers:[/b] Copilot (AI) (Commented)\n[b]Assignees:[/b] MonaLisa (Mona Lisa), Copilot (AI)\n\n [muted]...[/muted]\n\n[b]Copilot (AI)[/b] commented • [b]3m[/b] • [link]Newest comment[/link]\n\n [muted]...[/muted]\n\n[muted]View this pull request on GitHub: https://github.com/monalisa/my-project/pull/111[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-repo-view.json b/.github/extensions/terminal-mockup/library/sample-repo-view.json new file mode 100644 index 00000000000..d1cb4eff7bb --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-repo-view.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh repo view", + "savedAt": "2026-06-06T19:17:45.741Z", + "content": "[muted]$[/muted] [b]gh repo view monalisa/my-project[/b]\n[b]monalisa/my-project[/b]\nA delightful little project for delightful little tasks.\n\n Built with care by [b]MonaLisa[/b] and 12 contributors.\n Licensed under [b]MIT[/b].\n\n[b]Languages:[/b] Go (78.4%) • TypeScript (14.2%) • Shell (7.4%)\n[b]Stars:[/b] 1,247\n[b]Watchers:[/b] 38\n[b]Forks:[/b] 92\n[b]Open issues:[/b] 21\n[b]Open PRs:[/b] 4\n\n[muted]View this repository on GitHub: https://github.com/monalisa/my-project[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-run-watch.json b/.github/extensions/terminal-mockup/library/sample-run-watch.json new file mode 100644 index 00000000000..9d09df157c1 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-run-watch.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh run watch", + "savedAt": "2026-06-06T19:17:45.741Z", + "content": "[muted]$[/muted] [b]gh run watch[/b]\n\nRefreshing run status every 3 seconds. Press Ctrl+C to quit.\n\n[brgreen]✓[/brgreen] trunk CI · [muted]4815162342[/muted]\nTriggered via push about 1 minute ago\n\n[brgreen]JOBS[/brgreen]\n[brgreen]✓[/brgreen] lint in 12s ([link]ID 8675309001[/link])\n[brgreen]✓[/brgreen] test (ubuntu-latest) in 1m4s ([link]ID 8675309002[/link])\n[brgreen]✓[/brgreen] test (macos-latest) in 1m22s ([link]ID 8675309003[/link])\n[brgreen]✓[/brgreen] test (windows-latest) in 1m41s ([link]ID 8675309004[/link])\n[brgreen]✓[/brgreen] build in 38s ([link]ID 8675309005[/link])\n\n[brgreen]✓[/brgreen] Run trunk CI completed with 'success'", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/workflows/bump-go.yml b/.github/workflows/bump-go.yml index f9647b21064..71e8070b574 100644 --- a/.github/workflows/bump-go.yml +++ b/.github/workflows/bump-go.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 457e929c356..a43f1d83112 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,38 +25,29 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Go if: matrix.language == 'go' - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: languages: ${{ matrix.language }} queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: category: "/language:${{ matrix.language }}" upload: false output: sarif-results - - name: Filter SARIF for third-party code - if: matrix.language == 'go' - uses: advanced-security/filter-sarif@2da736ff05ef065cb2894ac6892e47b5eac2c3c0 # v1.1.0.1.1 - with: - patterns: | - -third-party/** - input: sarif-results/${{ matrix.language }}.sarif - output: sarif-results/${{ matrix.language }}.sarif - - name: Upload filtered SARIF - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: sarif_file: sarif-results/${{ matrix.language }}.sarif category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index ebda8eda5f6..f01dabe88a6 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -32,8 +32,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Validate tag name format + env: + TAG_NAME: ${{ inputs.tag_name }} run: | - if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid tag name format. Must be in the form v1.2.3" exit 1 fi @@ -44,13 +46,13 @@ jobs: if: contains(inputs.platforms, 'linux') steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -70,7 +72,7 @@ jobs: run: | go run ./cmd/gen-docs --website --doc-path dist/manual tar -czvf dist/manual.tar.gz -C dist -- manual - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linux if-no-files-found: error @@ -87,9 +89,9 @@ jobs: if: contains(inputs.platforms, 'macos') steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' - name: Configure macOS signing @@ -111,7 +113,7 @@ jobs: security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain" rm "$RUNNER_TEMP/cert.p12" - name: Install GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -150,7 +152,7 @@ jobs: run: | shopt -s failglob script/pkgmacos "$TAG_NAME" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: macos if-no-files-found: error @@ -167,13 +169,13 @@ jobs: if: contains(inputs.platforms, 'windows') steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -267,7 +269,7 @@ jobs: Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object { .\script\sign.ps1 $_.FullName } - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows if-no-files-found: error @@ -283,11 +285,11 @@ jobs: if: inputs.release steps: - name: Checkout cli/cli - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Merge built artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - name: Checkout documentation site - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: github/cli.github.com path: site @@ -338,7 +340,7 @@ jobs: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: "dist/gh_*" create-storage-record: false # (default: true) @@ -412,14 +414,4 @@ jobs: else git log --oneline @{upstream}.. git diff --name-status @{upstream}.. - fi - - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 - if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') - with: - formula-name: gh - formula-path: Formula/g/gh.rb - tag-name: ${{ inputs.tag_name }} - push-to: williammartin/homebrew-core - env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} + fi \ No newline at end of file diff --git a/.github/workflows/detect-spam.yml b/.github/workflows/detect-spam.yml index fd259bd640c..51dfe99bc91 100644 --- a/.github/workflows/detect-spam.yml +++ b/.github/workflows/detect-spam.yml @@ -4,20 +4,19 @@ on: types: [opened] permissions: - contents: none - issues: write - models: read + contents: read # check out the repo to run the spam-detection scripts. + issues: write # read issue contents (gh issue view), comment, label, and close issues detected as spam. + models: read # run inference via `gh models run` for spam classification. jobs: issue-spam: runs-on: ubuntu-latest - environment: cli-automation steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run spam detection env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }} + GH_TOKEN: ${{ github.token }} ISSUE_URL: ${{ github.event.issue.html_url }} run: | ./.github/workflows/scripts/spam-detection/process-issue.sh "$ISSUE_URL" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index da2f7379e96..237450c0510 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" @@ -45,10 +45,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 530e7f4c97b..c072f15b38d 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -1,7 +1,7 @@ name: Go Vulnerability Check on: schedule: - - cron: "0 0 * * 1" # Every Monday at midnight UTC + - cron: "0 0 * * *" # Every day at midnight UTC workflow_dispatch: jobs: @@ -12,10 +12,10 @@ jobs: security-events: write steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' @@ -26,6 +26,6 @@ jobs: go run golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77 -format sarif ./... > gh.sarif - name: Upload SARIF report - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: sarif_file: gh.sarif diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml deleted file mode 100644 index eccf933dd77..00000000000 --- a/.github/workflows/homebrew-bump.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: homebrew-bump-debug - -permissions: - contents: write - -on: - workflow_dispatch: - inputs: - tag_name: - required: true - type: string - environment: - default: production - type: environment -jobs: - bump: - runs-on: ubuntu-latest - steps: - - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 - if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') - with: - formula-name: gh - tag-name: ${{ inputs.tag_name }} - env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d55a944c854..aa597ff8658 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,10 +23,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' @@ -46,7 +46,7 @@ jobs: exit $STATUS - name: golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: version: v2.11.0 @@ -67,10 +67,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' diff --git a/.github/workflows/scripts/bump-go.sh b/.github/workflows/scripts/bump-go.sh index f0762f3d3c2..16dd346e815 100755 --- a/.github/workflows/scripts/bump-go.sh +++ b/.github/workflows/scripts/bump-go.sh @@ -1,15 +1,15 @@ #!/usr/bin/env bash # -# bump-go.sh — Update go.mod `go` directive and toolchain to latest stable Go release. +# bump-go.sh -- Update go.mod `go` directive and toolchain to latest stable Go release. # # Usage: # ./bump-go.sh [--apply|-a] # -# By default the script runs in *dry‑run* mode: it creates a local branch, +# By default the script runs in *dry-run* mode: it creates a local branch, # commits the version bump, shows the exact patch, **checks for an existing PR** # with the same title, and exits. Nothing is pushed. The temporary branch is # deleted automatically on exit, so your working tree stays clean. Pass -# --apply (or -a) to push the branch and open a new PR *only if one doesn’t +# --apply (or -a) to push the branch and open a new PR *only if one doesn't # already exist*. # ----------------------------------------------------------------------------- set -euo pipefail @@ -35,52 +35,66 @@ done [[ -z "$GO_MOD" ]] && usage [[ -f "$GO_MOD" ]] || { echo "Error: '$GO_MOD' not found" >&2; exit 1; } +REPO="cli/cli" +MODULE_DIR=$(dirname "$GO_MOD") +GO_SUM="$MODULE_DIR/go.sum" + # ---- Discover latest stable Go release -------------------------------------- -echo "Fetching latest stable Go version…" +echo "Fetching latest stable Go version..." LATEST_JSON=$(curl -fsSL https://go.dev/dl/?mode=json | jq -c '[.[] | select(.stable==true)][0]') FULL_VERSION=$(jq -r '.version' <<< "$LATEST_JSON") # e.g. go1.23.4 TOOLCHAIN_VERSION="${FULL_VERSION#go}" # e.g. 1.23.4 -# `go mod tidy` will always add `.0` if there is no minor version -# so let's just ensure .0 is suffixed to the go directive. GO_DIRECTIVE_VERSION="$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION").0" -echo " → go : $GO_DIRECTIVE_VERSION" -echo " → toolchain : $TOOLCHAIN_VERSION" +echo " → go directive : $GO_DIRECTIVE_VERSION" +echo " → toolchain : go$TOOLCHAIN_VERSION" -# ---- Prepare Git branch --------------------------------------------------- -CURRENT_GO_DIRECTIVE=$(grep -E '^go ' "$GO_MOD" | cut -d ' ' -f2) -CURRENT_TOOLCHAIN_DIRECTIVE=$(grep -E '^toolchain ' "$GO_MOD" | cut -d ' ' -f2) +# ---- Read current go.mod state using go mod edit ---------------------------- +GO_MOD_JSON=$(go mod edit -json "$GO_MOD") +CURRENT_GO_DIRECTIVE=$(jq -r '.Go // ""' <<< "$GO_MOD_JSON") +CURRENT_TOOLCHAIN=$(jq -r '.Toolchain // ""' <<< "$GO_MOD_JSON") -if [[ "$CURRENT_GO_DIRECTIVE" = "$GO_DIRECTIVE_VERSION" && \ - "$CURRENT_TOOLCHAIN_DIRECTIVE" = "go$TOOLCHAIN_VERSION" ]]; then - echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (toolchain: $CURRENT_TOOLCHAIN_DIRECTIVE)" - exit 0 -fi +echo " → current go : $CURRENT_GO_DIRECTIVE" +echo " → current tc : ${CURRENT_TOOLCHAIN:-(none)}" +# ---- Prepare Git branch ----------------------------------------------------- BRANCH="bump-go-$TOOLCHAIN_VERSION" +BRANCH_CREATED=0 + cleanup() { - git checkout - >/dev/null 2>&1 || true - git branch -D "$BRANCH" >/dev/null 2>&1 || true + if [[ $BRANCH_CREATED -eq 1 ]]; then + git checkout - >/dev/null 2>&1 || true + git branch -D "$BRANCH" >/dev/null 2>&1 || true + fi } trap cleanup EXIT echo "Creating branch $BRANCH" git switch -c "$BRANCH" >/dev/null 2>&1 +BRANCH_CREATED=1 # ---- Patch go.mod ----------------------------------------------------------- -if [[ "$CURRENT_GO_DIRECTIVE" != "$GO_DIRECTIVE_VERSION" ]]; then - sed -Ei.bak "s/^go [0-9]+\.[0-9]+.*$/go $GO_DIRECTIVE_VERSION/" "$GO_MOD" - echo " • go directive $CURRENT_GO_DIRECTIVE → $GO_DIRECTIVE_VERSION" -fi - -if [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" != "go$TOOLCHAIN_VERSION" ]]; then - sed -Ei.bak "s/^toolchain go[0-9]+\.[0-9]+\.[0-9]+.*$/toolchain go$TOOLCHAIN_VERSION/" "$GO_MOD" - echo " • toolchain $CURRENT_TOOLCHAIN_DIRECTIVE → go$TOOLCHAIN_VERSION" +# Always set both directives and let `go mod tidy` normalize. +# When the go directive version matches the toolchain version, tidy will remove +# the toolchain line because it is redundant -- this is expected Go behavior. +go mod edit -go="$GO_DIRECTIVE_VERSION" -toolchain="go$TOOLCHAIN_VERSION" "$GO_MOD" +echo " • set go directive → $GO_DIRECTIVE_VERSION" +echo " • set toolchain → go$TOOLCHAIN_VERSION" + +# Let go mod tidy reconcile dependencies and normalize directives. +echo " • running go mod tidy..." +pushd "$MODULE_DIR" > /dev/null +go mod tidy +popd > /dev/null + +# ---- Check if anything actually changed ------------------------------------- +if git diff --quiet -- "$GO_MOD" "$GO_SUM" 2>/dev/null; then + echo "Already on latest Go version -- no changes needed." + exit 0 fi -rm -f "$GO_MOD.bak" - git add "$GO_MOD" +[[ -f "$GO_SUM" ]] && git add "$GO_SUM" # ---- Commit ----------------------------------------------------------------- COMMIT_MSG="Bump Go to $TOOLCHAIN_VERSION" @@ -90,31 +104,43 @@ COMMIT_HASH=$(git rev-parse --short HEAD) PR_TITLE="$COMMIT_MSG" # ---- Check for existing PR -------------------------------------------------- -existing_pr=$(gh search prs --repo cli/cli --match title "$PR_TITLE" --json title --jq "map(select(.title == \"$PR_TITLE\") | .title) | length > 0") +existing_pr=$(gh search prs --repo "$REPO" --state open --match title "$PR_TITLE" \ + --json title --jq "map(select(.title == \"$PR_TITLE\") | .title) | length > 0") if [[ "$existing_pr" == "true" ]]; then echo "Found an existing open PR titled '$PR_TITLE'. Skipping push/PR creation." if [[ $APPLY -eq 0 ]]; then - echo -e "\n=== DRY‑RUN DIFF (commit $COMMIT_HASH):\n" + echo -e "\n=== DRY-RUN DIFF (commit $COMMIT_HASH):\n" git --no-pager show --color "$COMMIT_HASH" fi exit 0 fi -# ---- Dry‑run handling ------------------------------------------------------- +# ---- Dry-run handling ------------------------------------------------------- if [[ $APPLY -eq 0 ]]; then - echo -e "\n=== DRY‑RUN DIFF (commit $COMMIT_HASH):\n" + echo -e "\n=== DRY-RUN DIFF (commit $COMMIT_HASH):\n" git --no-pager show --color "$COMMIT_HASH" echo -e "\nIf --apply were provided, script would continue with:\n git push -u origin $BRANCH\n gh pr create --title \"$PR_TITLE\" --body \n" exit 0 fi # ---- Push & PR -------------------------------------------------------------- +FINAL_GO_MOD_JSON=$(go mod edit -json "$GO_MOD") +FINAL_GO=$(jq -r '.Go // ""' <<< "$FINAL_GO_MOD_JSON") +FINAL_TC=$(jq -r '.Toolchain // ""' <<< "$FINAL_GO_MOD_JSON") + +# Build PR body reflecting final state after tidy +if [[ -n "$FINAL_TC" ]]; then + TC_LINE="* **toolchain:** \`$FINAL_TC\`" +else + TC_LINE="* **toolchain:** _(none -- \`go mod tidy\` removed it because the go directive already implies go$TOOLCHAIN_VERSION)_" +fi + PR_BODY=$(cat <- github.event_name == 'pull_request_target' && - (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited') + (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited' || github.event.action == 'ready_for_review') uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main + with: + enable_pr_screening: true + days_until_close: 4 + large_pr_days_until_close: 2 permissions: issues: read pull-requests: write @@ -38,6 +42,10 @@ jobs: close-unmet-requirements: if: github.event_name == 'schedule' uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main + with: + enable_pr_screening: true + days_until_close: 4 + large_pr_days_until_close: 2 permissions: issues: read pull-requests: write diff --git a/.gitignore b/.gitignore index b82a00c7274..25549846a52 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /share/fish/vendor_completions.d /share/man/man1 /share/zsh/site-functions +/share/zsh/vendor-completions /gh-cli .envrc /dist @@ -38,3 +39,8 @@ *~ vendor/ +gh + +# Test coverage artifacts +coverage.out +lcov.info diff --git a/.golangci.yml b/.golangci.yml index 932a4b4384b..f50707936b2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,8 +29,6 @@ linters: # - staticcheck # - errcheck exclusions: - paths: - - third-party rules: - path: _test\.go$ linters: @@ -62,9 +60,6 @@ linters: formatters: enable: - gofmt - exclusions: - paths: - - third-party issues: max-issues-per-linter: 0 diff --git a/.goreleaser.yml b/.goreleaser.yml index b264b58e86c..9dd3c3e00bc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -106,3 +106,8 @@ nfpms: #build:linux dst: "/usr/share/fish/vendor_completions.d/gh.fish" - src: "./share/zsh/site-functions/_gh" dst: "/usr/share/zsh/site-functions/_gh" + # Debian/Ubuntu zsh does not look in /usr/share/zsh/site-functions by default, + # so we also install to vendor-completions. See https://github.com/cli/cli/issues/13166 + - src: "./share/zsh/vendor-completions/_gh" + dst: "/usr/share/zsh/vendor-completions/_gh" + packager: deb diff --git a/AGENTS.md b/AGENTS.md index b04e6b77557..a9e3ab10951 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,7 @@ for _, tt := range tests { - Add godoc comments to all exported functions, types, and constants - Avoid unnecessary code comments — only comment when the *why* isn't obvious from the code - Do not comment just to restate what the code does +- Never use em dashes (—) in code, comments, or documentation; use regular dashes (-) or rewrite the sentence instead ## Error Handling diff --git a/Makefile b/Makefile index 4efdbfbed1b..c3b18f31332 100644 --- a/Makefile +++ b/Makefile @@ -33,10 +33,18 @@ manpages: script/build$(EXE) .PHONY: completions completions: bin/gh$(EXE) - mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions + mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions ./share/zsh/vendor-completions bin/gh$(EXE) completion -s bash > ./share/bash-completion/completions/gh bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh + # On Debian/Ubuntu the default zsh fpath does not include /usr/share/zsh/site-functions + # but does include /usr/share/zsh/vendor-completions, so we ship both paths in our + # .deb and .rpm packages. See https://github.com/cli/cli/issues/13166 + cp ./share/zsh/site-functions/_gh ./share/zsh/vendor-completions/_gh + +.PHONY: lint +lint: + golangci-lint run ./... # just convenience tasks around `go test` .PHONY: test diff --git a/README.md b/README.md index e67a7effc04..f0961571b10 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ For information on all pre-installed tools, see [`actions/runner-images`](https: ### Verification of binaries +Starting with v2.93.0, releases of `gh` are published as immutable releases. For more information, see [Immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases). + Since version 2.50.0, `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/), enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision, and build instructions used. The build provenance attestations are signed and rely on Public Good [Sigstore](https://www.sigstore.dev/) for PKI. There are two common ways to verify a downloaded release, depending on whether `gh` is already installed or not. If `gh` is installed, it's trivial to verify a new release: diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 98642afafeb..b6cf48b043a 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -14,9 +14,9 @@ import ( "math/rand" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghcmd" "github.com/cli/go-internal/testscript" - "github.com/MakeNowJust/heredoc" ) func ghMain() int { @@ -74,6 +74,15 @@ func TestIssues(t *testing.T) { testscript.Run(t, testScriptParamsFor(tsEnv, "issue")) } +func TestIssues2_0(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "issues-2.0")) +} + func TestLabels(t *testing.T) { var tsEnv testScriptEnv if err := tsEnv.fromEnv(); err != nil { @@ -182,6 +191,15 @@ func TestWorkflows(t *testing.T) { testscript.Run(t, testScriptParamsFor(tsEnv, "workflow")) } +func TestTelemetry(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "telemetry")) +} + func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params { var files []string if tsEnv.script != "" { @@ -226,6 +244,8 @@ func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error { ts.Setenv("RANDOM_STRING", randomString(10)) + ts.Setenv("GH_TELEMETRY", "false") + // The sandbox overrides HOME, so git cannot find the user's global // config. Write a minimal identity so commits inside the sandbox // don't fail with "Author identity unknown". @@ -434,3 +454,11 @@ func (e *testScriptEnv) fromEnv() error { return nil } + +func TestSkills(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + testscript.Run(t, testScriptParamsFor(tsEnv, "skills")) +} diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar new file mode 100644 index 00000000000..a1fde33a96c --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar @@ -0,0 +1,28 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create an issue with --type +exec gh issue create --title 'with type' --body '' --type 'Bug' +stdout2env ISSUE_URL + +# Confirm the type stuck +exec gh issue view $ISSUE_URL --json issueType --jq .issueType.name +stdout '^Bug$' + +# Clear the type with --remove-type +exec gh issue edit $ISSUE_URL --remove-type +exec gh issue view $ISSUE_URL --json issueType --jq '.issueType // "null"' +stdout '^null$' + +# Set the type back with --type +exec gh issue edit $ISSUE_URL --type 'Bug' +exec gh issue view $ISSUE_URL --json issueType --jq .issueType.name +stdout '^Bug$' diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar new file mode 100644 index 00000000000..707fc1df793 --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar @@ -0,0 +1,32 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create the parent issue +exec gh issue create --title 'parent' --body '' +stdout2env PARENT_URL + +# Create a child via --parent on create +exec gh issue create --title 'child via create' --body '' --parent $PARENT_URL +stdout2env CHILD_URL + +# Confirm parent is set +exec gh issue view $CHILD_URL --json parent --jq .parent.url +stdout $PARENT_URL + +# Clear the parent with --remove-parent +exec gh issue edit $CHILD_URL --remove-parent +exec gh issue view $CHILD_URL --json parent --jq '.parent // "null"' +stdout '^null$' + +# Set the parent back with --parent on edit +exec gh issue edit $CHILD_URL --parent $PARENT_URL +exec gh issue view $CHILD_URL --json parent --jq .parent.url +stdout $PARENT_URL diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar new file mode 100644 index 00000000000..4ea8762a79c --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar @@ -0,0 +1,54 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create the two helper issues that the main issue will block / be blocked by +exec gh issue create --title 'blocker' --body '' +stdout2env BLOCKER_URL + +exec gh issue create --title 'blocked' --body '' +stdout2env BLOCKED_URL + +# Create the main issue with both relationships set on create +exec gh issue create --title 'main' --body '' --blocked-by $BLOCKER_URL --blocking $BLOCKED_URL +stdout2env MAIN_URL + +# Confirm both relationships landed +exec gh issue view $MAIN_URL --json blockedBy --jq '.blockedBy.nodes[].url' +stdout $BLOCKER_URL + +exec gh issue view $MAIN_URL --json blocking --jq '.blocking.nodes[].url' +stdout $BLOCKED_URL + +# Add a second blocker / blocked via edit +exec gh issue create --title 'blocker 2' --body '' +stdout2env BLOCKER_2_URL + +exec gh issue create --title 'blocked 2' --body '' +stdout2env BLOCKED_2_URL + +exec gh issue edit $MAIN_URL --add-blocked-by $BLOCKER_2_URL --add-blocking $BLOCKED_2_URL + +exec gh issue view $MAIN_URL --json blockedBy --jq '.blockedBy.totalCount' +stdout '^2$' + +exec gh issue view $MAIN_URL --json blocking --jq '.blocking.totalCount' +stdout '^2$' + +# Remove the original blocker / blocked +exec gh issue edit $MAIN_URL --remove-blocked-by $BLOCKER_URL --remove-blocking $BLOCKED_URL + +exec gh issue view $MAIN_URL --json blockedBy --jq '.blockedBy.nodes[].title' +stdout '^blocker 2$' +! stdout '^blocker$' + +exec gh issue view $MAIN_URL --json blocking --jq '.blocking.nodes[].title' +stdout '^blocked 2$' +! stdout '^blocked$' diff --git a/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar b/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar new file mode 100644 index 00000000000..94d0c9621ae --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar @@ -0,0 +1,35 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create three issues: parent A, parent B, candidate C +exec gh issue create --title 'parent A' --body '' +stdout2env PARENT_A_URL + +exec gh issue create --title 'parent B' --body '' +stdout2env PARENT_B_URL + +exec gh issue create --title 'candidate C' --body '' +stdout2env CANDIDATE_URL + +# Add C as a sub-issue of A +exec gh issue edit $PARENT_A_URL --add-sub-issue $CANDIDATE_URL +exec gh issue view $CANDIDATE_URL --json parent --jq .parent.url +stdout $PARENT_A_URL + +# Adding C as a sub-issue of B silently overwrites the existing parent +exec gh issue edit $PARENT_B_URL --add-sub-issue $CANDIDATE_URL +exec gh issue view $CANDIDATE_URL --json parent --jq .parent.url +stdout $PARENT_B_URL + +# Removing the sub-issue from B drops the parent +exec gh issue edit $PARENT_B_URL --remove-sub-issue $CANDIDATE_URL +exec gh issue view $CANDIDATE_URL --json parent --jq '.parent // "null"' +stdout '^null$' diff --git a/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar b/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar new file mode 100644 index 00000000000..5150857c6f5 --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar @@ -0,0 +1,21 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create one Bug-typed issue and one untyped issue +exec gh issue create --title 'typed-bug' --body '' --type 'Bug' +exec gh issue create --title 'untyped' --body '' + +sleep 3 + +# Filtering by type returns only the typed issue +exec gh issue list --type 'Bug' +stdout 'typed-bug' +! stdout 'untyped' diff --git a/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar b/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar new file mode 100644 index 00000000000..f73043de2dd --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar @@ -0,0 +1,39 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create a parent, a sub-issue, a blocker, and a blocked target +exec gh issue create --title 'parent' --body '' +stdout2env PARENT_URL + +exec gh issue create --title 'sub' --body '' +stdout2env SUB_URL + +exec gh issue create --title 'blocker' --body '' +stdout2env BLOCKER_URL + +exec gh issue create --title 'blocked' --body '' +stdout2env BLOCKED_URL + +# Create the main issue wired up to all four +exec gh issue create --title 'main' --body '' --type 'Bug' --parent $PARENT_URL --blocked-by $BLOCKER_URL --blocking $BLOCKED_URL +stdout2env MAIN_URL + +# Attach the sub-issue +exec gh issue edit $MAIN_URL --add-sub-issue $SUB_URL + +# Non-tty view should include all the new Issues 2.0 fields +exec gh issue view $MAIN_URL +stdout '^issue-type:\tBug$' +stdout '^parent:\t.+/.+#[0-9]+$' +stdout '^sub-issues:\t.+/.+#[0-9]+$' +stdout '^sub-issues-completed:\t0/1$' +stdout '^blocked-by:\t.+/.+#[0-9]+$' +stdout '^blocking:\t.+/.+#[0-9]+$' diff --git a/acceptance/testdata/skills/skills-install-force.txtar b/acceptance/testdata/skills/skills-install-force.txtar new file mode 100644 index 00000000000..e6bd520b9cf --- /dev/null +++ b/acceptance/testdata/skills/skills-install-force.txtar @@ -0,0 +1,11 @@ +# Install with --force should overwrite an existing skill without error +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test +stdout 'Installed git-commit' + +# Install again with --force — should succeed (overwrite) +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test +stdout 'Installed git-commit' + +# Without --force, non-interactive should fail when skill exists +! exec gh skill install github/awesome-copilot git-commit --dir $WORK/force-test +stderr 'already installed' diff --git a/acceptance/testdata/skills/skills-install-from-local.txtar b/acceptance/testdata/skills/skills-install-from-local.txtar new file mode 100644 index 00000000000..0b003fd3ef2 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-from-local.txtar @@ -0,0 +1,15 @@ +# Install from a local directory using --from-local +exec gh skill install --from-local $WORK/local-repo git-commit --dir $WORK/output --force +stdout 'Installed git-commit' + +# Verify the skill was copied +exists $WORK/output/git-commit/SKILL.md +grep 'local-path' $WORK/output/git-commit/SKILL.md + +-- local-repo/skills/git-commit/SKILL.md -- +--- +name: git-commit +description: Write good git commits +--- +# Git Commit +Body content. diff --git a/acceptance/testdata/skills/skills-install-invalid-agent.txtar b/acceptance/testdata/skills/skills-install-invalid-agent.txtar new file mode 100644 index 00000000000..7e85a9faea1 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-agent.txtar @@ -0,0 +1,4 @@ +# Invalid agent ID should error with valid options +! exec gh skill install github/awesome-copilot git-commit --agent bogus-agent --force +stderr 'invalid argument' +stderr 'github-copilot' diff --git a/acceptance/testdata/skills/skills-install-invalid-repo.txtar b/acceptance/testdata/skills/skills-install-invalid-repo.txtar new file mode 100644 index 00000000000..2b59582e19d --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-repo.txtar @@ -0,0 +1,3 @@ +# Nonexistent repo should error +! exec gh skill install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp +stderr 'Not Found' diff --git a/acceptance/testdata/skills/skills-install-namespaced.txtar b/acceptance/testdata/skills/skills-install-namespaced.txtar new file mode 100644 index 00000000000..9aa83ef5650 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-namespaced.txtar @@ -0,0 +1,61 @@ +# Two namespaced skills with different base names in the same repo should +# be independently installable using path-based disambiguation. +# Skills are installed flat (by base name) so each must have a unique name. + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repo with two namespaced skills that have unique base names +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --public --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +cd $SCRIPT_NAME-$RANDOM_STRING + +mkdir -p skills/alice/alice-deploy +mkdir -p skills/bob/bob-deploy +cp $WORK/alice-skill.md skills/alice/alice-deploy/SKILL.md +cp $WORK/bob-skill.md skills/bob/bob-deploy/SKILL.md + +exec git add -A +exec git commit -m 'Add namespaced skills' +exec git push origin main + +# Publish so the skills are discoverable +exec gh skill publish --tag v1.0.0 + +# Install alice's skill using the full path to disambiguate +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/alice/alice-deploy --scope user --force +stdout 'Installed alice/alice-deploy' + +# Install bob's skill using the full path +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/bob/bob-deploy --scope user --force +stdout 'Installed bob/bob-deploy' + +# Verify both were installed to flat directories (by base name) +exists $HOME/.copilot/skills/alice-deploy/SKILL.md +exists $HOME/.copilot/skills/bob-deploy/SKILL.md + +# Verify each has the correct content +grep 'Alice' $HOME/.copilot/skills/alice-deploy/SKILL.md +grep 'Bob' $HOME/.copilot/skills/bob-deploy/SKILL.md + +-- alice-skill.md -- +--- +name: alice-deploy +description: Alice's deployment skill +--- + +# Deploy by Alice + +Deploys infrastructure using Alice's conventions. + +-- bob-skill.md -- +--- +name: bob-deploy +description: Bob's deployment skill +--- + +# Deploy by Bob + +Deploys infrastructure using Bob's conventions. diff --git a/acceptance/testdata/skills/skills-install-nested-files.txtar b/acceptance/testdata/skills/skills-install-nested-files.txtar new file mode 100644 index 00000000000..c4fe085e446 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nested-files.txtar @@ -0,0 +1,3 @@ +# Install a skill that has nested subdirectories and verify file tree +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/nested-test +exists $WORK/nested-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar new file mode 100644 index 00000000000..44187c4ff8d --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar @@ -0,0 +1,3 @@ +# Installing a skill that doesn't exist in a valid repo should error +! exec gh skill install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp +stderr 'not found' diff --git a/acceptance/testdata/skills/skills-install-pin.txtar b/acceptance/testdata/skills/skills-install-pin.txtar new file mode 100644 index 00000000000..7c87e4b33ff --- /dev/null +++ b/acceptance/testdata/skills/skills-install-pin.txtar @@ -0,0 +1,7 @@ +# Install with --pin to a specific ref +exec gh skill install github/awesome-copilot git-commit --scope user --force --pin main +stdout 'Installed git-commit' + +# Install without --pin should resolve latest version +exec gh skill install github/awesome-copilot git-commit --scope user --force +stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar new file mode 100644 index 00000000000..52270178a08 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -0,0 +1,9 @@ +# Install with --scope project writes to the git repo's .agents/skills/ +exec git init --initial-branch=main $WORK/myrepo +cd $WORK/myrepo +exec gh skill install github/awesome-copilot git-commit --scope project --force --agent github-copilot +exists $WORK/myrepo/.agents/skills/git-commit/SKILL.md + +# Install with --scope user writes to home directory +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot +exists $HOME/.copilot/skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar new file mode 100644 index 00000000000..442edb797f6 --- /dev/null +++ b/acceptance/testdata/skills/skills-install.txtar @@ -0,0 +1,32 @@ +# Install a single skill from a public repo +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stdout 'Installed git-commit' + +# Verify SKILL.md has frontmatter metadata injected +exists $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-repo' $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-tree-sha' $HOME/.copilot/skills/git-commit/SKILL.md + +# Verify lockfile was written +exists $HOME/.agents/.skill-lock.json +grep 'git-commit' $HOME/.agents/.skill-lock.json + +# Install with --dir to a custom directory +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/custom-skills +stdout 'Installed git-commit' + +# Verify the skill was written to the custom directory +exists $WORK/custom-skills/git-commit/SKILL.md +grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md + +# Telemetry: skill_install event records agent hosts, repo identifiers, +# and (for a public repo) the installed skill name. +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stderr 'Telemetry payload:' +stderr '"type": "skill_install"' +stderr '"agent_hosts": "github-copilot"' +stderr '"skill_host_type": "github.com"' +stderr '"skill_owner": "github"' +stderr '"skill_repo": "awesome-copilot"' diff --git a/acceptance/testdata/skills/skills-preview-noninteractive.txtar b/acceptance/testdata/skills/skills-preview-noninteractive.txtar new file mode 100644 index 00000000000..7c276b8d32a --- /dev/null +++ b/acceptance/testdata/skills/skills-preview-noninteractive.txtar @@ -0,0 +1,3 @@ +# Preview with repo only and non-interactive should error +! exec gh skill preview github/awesome-copilot +stderr 'must specify a skill name' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar new file mode 100644 index 00000000000..76aa9a6ecb1 --- /dev/null +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -0,0 +1,20 @@ +# Preview renders skill content and file tree +exec gh skill preview github/awesome-copilot git-commit +stdout 'SKILL.md' +# Verify actual content is rendered, not just the filename +stdout 'git-commit/' + +# Preview a skill that doesn't exist should error +! exec gh skill preview github/awesome-copilot nonexistent-skill-xyz +stderr 'not found' + +# Telemetry: skill_preview event records repo identifiers and, for a +# public repo, the skill name. +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +exec gh skill preview github/awesome-copilot git-commit +stderr 'Telemetry payload:' +stderr '"type": "skill_preview"' +stderr '"skill_host_type": "github.com"' +stderr '"skill_owner": "github"' +stderr '"skill_repo": "awesome-copilot"' diff --git a/acceptance/testdata/skills/skills-publish-dir-remote.txtar b/acceptance/testdata/skills/skills-publish-dir-remote.txtar new file mode 100644 index 00000000000..8f833a76ca4 --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dir-remote.txtar @@ -0,0 +1,58 @@ +# When a directory argument is provided to `gh skill publish --dry-run`, +# the remote detection must use the target directory's git remotes, +# not the current working directory's remotes. +# +# This test creates two separate git repos: +# - cwd-repo (the working directory) with remote pointing to owner/cwd-repo +# - target-repo (the dir argument) with remote pointing to owner/target-repo +# +# If the bug is present, the command would detect cwd-repo's remote instead of +# target-repo's remote. + +# Set up credential helper +exec gh auth setup-git + +# Create two test repos on GitHub +exec gh repo create $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +exec gh repo create $ORG/$SCRIPT_NAME-target-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-target-$RANDOM_STRING + +# Clone both repos +exec gh repo clone $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING cwd-repo +exec gh repo clone $ORG/$SCRIPT_NAME-target-$RANDOM_STRING target-repo + +# Add a skill to the target repo only +mkdir target-repo/skills/hello-world +cp $WORK/skill.md target-repo/skills/hello-world/SKILL.md +exec git -C $WORK/target-repo add -A +exec git -C $WORK/target-repo commit -m 'Add test skill' +exec git -C $WORK/target-repo push origin main + +# Run publish dry-run from cwd-repo, pointing at target-repo +cd cwd-repo +exec gh skill publish --dry-run $WORK/target-repo + +# Verify the output references the target repo, not the cwd repo +stdout 'hello-world' + +# Publish with a tag from within cwd-repo, targeting target-repo +exec gh skill publish --tag v0.1.0 $WORK/target-repo + +# Verify the release was created on the TARGET repo, not the cwd repo +exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-target-$RANDOM_STRING +stdout 'v0.1.0' + +# Verify NO release was created on the cwd repo +! exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +-- skill.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly. diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar new file mode 100644 index 00000000000..fe4d160c314 --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -0,0 +1,26 @@ +# Publish dry-run from a directory with no skills/ should fail gracefully +mkdir $WORK/empty-dir +! exec gh skill publish --dry-run $WORK/empty-dir +stderr 'no skills found in' + +# Publish dry-run against a valid skill directory should succeed +exec gh skill publish --dry-run $WORK/test-repo +stdout 'hello-world' + +# Publish dry-run with --tag +exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo +stdout 'hello-world' + +-- test-repo/skills/hello-world/SKILL.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly. + +-- test-repo/skills/hello-world/scripts/setup.sh -- +#!/bin/bash +echo "Hello from the hello-world skill!" diff --git a/acceptance/testdata/skills/skills-publish-lifecycle.txtar b/acceptance/testdata/skills/skills-publish-lifecycle.txtar new file mode 100644 index 00000000000..d3d6f0a3a72 --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-lifecycle.txtar @@ -0,0 +1,64 @@ +# Full publish lifecycle: create repo, publish, install from it, clean up + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a private repo for testing +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +cd $SCRIPT_NAME-$RANDOM_STRING + +# Add a test skill +mkdir skills/hello-world/scripts +cp $WORK/skill.md skills/hello-world/SKILL.md +cp $WORK/setup.sh skills/hello-world/scripts/setup.sh +exec git add -A +exec git commit -m 'Add test skill' +exec git push origin main + +# Publish with a tag +exec gh skill publish --tag v0.1.0 + +# Verify the release was created on GitHub +exec gh release view v0.1.0 +stdout 'v0.1.0' + +# Install from our test repo +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force +stdout 'Installed hello-world' + +# Verify installed files exist with correct metadata +exists $HOME/.copilot/skills/hello-world/SKILL.md +exists $HOME/.copilot/skills/hello-world/scripts/setup.sh +grep 'github-repo' $HOME/.copilot/skills/hello-world/SKILL.md + +# Install with --pin +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0 +stdout 'Installed hello-world' + +# Preview from our test repo +exec gh skill preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world +stdout 'Hello World' + +# Update dry-run should find installed skill +exec gh skill update --dry-run --all +stderr 'up to date' + +-- skill.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly and offer to run the setup script. + +-- setup.sh -- +#!/bin/bash +echo "Hello from the hello-world skill!" +echo "Setting up environment..." +echo "Done." diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar new file mode 100644 index 00000000000..c51d7b56811 --- /dev/null +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -0,0 +1,4 @@ +# Search for something unlikely to exist returns empty stdout +# NoResultsError is silent in non-TTY (exits 0 with no output) +exec gh skill search zzzznonexistenttotallyfakeskillxyz123 +! stdout . diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar new file mode 100644 index 00000000000..48409c2354d --- /dev/null +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -0,0 +1,3 @@ +# Pagination returns results on page 2 +exec gh skill search --owner github copilot --page 2 +stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar new file mode 100644 index 00000000000..e16936b0d1b --- /dev/null +++ b/acceptance/testdata/skills/skills-search.txtar @@ -0,0 +1,12 @@ +# Search for skills matching a query +exec gh skill search --owner github copilot +stdout 'copilot' + +# Search with JSON output +exec gh skill search copilot --json skillName,repo --limit 1 +stdout '"skillName"' +stdout '"repo"' + +# Search with a short query should error +! exec gh skill search a +stderr 'at least' \ No newline at end of file diff --git a/acceptance/testdata/skills/skills-update-noinstalled.txtar b/acceptance/testdata/skills/skills-update-noinstalled.txtar new file mode 100644 index 00000000000..7fd19541bc0 --- /dev/null +++ b/acceptance/testdata/skills/skills-update-noinstalled.txtar @@ -0,0 +1,5 @@ +# Update with no installed skills should report appropriately +exec gh skill update --dry-run --all --dir $WORK/empty-dir +stderr 'No installed skills found' + +-- empty-dir/.gitkeep -- diff --git a/acceptance/testdata/skills/skills-update.txtar b/acceptance/testdata/skills/skills-update.txtar new file mode 100644 index 00000000000..52933a5f86d --- /dev/null +++ b/acceptance/testdata/skills/skills-update.txtar @@ -0,0 +1,22 @@ +# Dry-run update should find the installed skill and report status +exec gh skill update --dry-run --all --dir $WORK/skills-dir +stdout 'git-commit' + +# Force update should re-download and rewrite files +exec gh skill update --force --all --dir $WORK/skills-dir +stdout 'Updated' + +# Verify the SKILL.md was rewritten with real content (not our placeholder) +grep 'github-repo' $WORK/skills-dir/git-commit/SKILL.md +! grep 'Test skill content' $WORK/skills-dir/git-commit/SKILL.md + +-- skills-dir/git-commit/SKILL.md -- +--- +name: git-commit +description: Git commit helper +metadata: + github-repo: https://github.com/github/awesome-copilot.git + github-tree-sha: 0000000000000000000000000000000000000000 + github-path: skills/git-commit +--- +Test skill content diff --git a/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar b/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar new file mode 100644 index 00000000000..2a10d23da71 --- /dev/null +++ b/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar @@ -0,0 +1,9 @@ +# Telemetry log mode records accessibility features as disabled by default +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh version +stderr '"accessible_colors": "false"' +stderr '"accessible_prompter": "false"' +stderr '"color_labels": "false"' +stderr '"spinner_disabled": "false"' diff --git a/acceptance/testdata/telemetry/accessibility-dimensions.txtar b/acceptance/testdata/telemetry/accessibility-dimensions.txtar new file mode 100644 index 00000000000..9df0b524019 --- /dev/null +++ b/acceptance/testdata/telemetry/accessibility-dimensions.txtar @@ -0,0 +1,13 @@ +# Telemetry log mode records accessibility feature state as dimensions +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_ACCESSIBLE_COLORS=true +env GH_ACCESSIBLE_PROMPTER=true +env GH_COLOR_LABELS=true +env GH_SPINNER_DISABLED=true + +exec gh version +stderr '"accessible_colors": "true"' +stderr '"accessible_prompter": "true"' +stderr '"color_labels": "true"' +stderr '"spinner_disabled": "true"' diff --git a/acceptance/testdata/telemetry/command-invocation.txtar b/acceptance/testdata/telemetry/command-invocation.txtar new file mode 100644 index 00000000000..d174c5c08f1 --- /dev/null +++ b/acceptance/testdata/telemetry/command-invocation.txtar @@ -0,0 +1,8 @@ +# Telemetry log mode outputs command invocation event to stderr +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh version +stderr 'Telemetry payload:' +stderr '"type": "command_invocation"' +stderr '"command": "gh version"' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar new file mode 100644 index 00000000000..2bfe0657dc2 --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar @@ -0,0 +1,17 @@ +# Aliases should not leak their user-defined names via telemetry, but the +# resolved inner command should still record normally — its path is a core +# gh command and conveys no user-authored identifier. + +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# Create a regular (non-shell) alias that resolves to an existing command. +exec gh alias set secret-project-alias version + +# Invoking the alias must not produce any event carrying the alias name. +exec gh secret-project-alias +! stderr 'secret-project-alias' + +# The resolved inner command still records telemetry as normal. +stderr 'Telemetry payload:' +stderr '"command": "gh version"' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar new file mode 100644 index 00000000000..1204a7913bb --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -0,0 +1,6 @@ +# The completion command should not generate a telemetry event +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh completion -s bash +stderr 'Telemetry payload: none' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar new file mode 100644 index 00000000000..5e9d2ea5d2a --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -0,0 +1,28 @@ +# Third-party extensions must not generate telemetry events, since the +# extension command name can be a user-authored identifier (e.g. an +# organization or project name). +[!exec:bash] skip + +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# Create a local shell extension repository +exec git init gh-hello +cp gh-hello.sh gh-hello/gh-hello +chmod 755 gh-hello/gh-hello +exec git -C gh-hello add gh-hello +exec git -C gh-hello commit -m 'init' + +# Install it locally +cd gh-hello +exec gh ext install . +cd $WORK + +# Run the extension and verify no telemetry is logged +exec gh hello +stdout 'hello from extension' +stderr 'Telemetry payload: none' + +-- gh-hello.sh -- +#!/usr/bin/env bash +echo "hello from extension" diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar new file mode 100644 index 00000000000..e8e1d8ffe97 --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -0,0 +1,7 @@ +# GHES users should not get telemetry even when telemetry is enabled +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_ENTERPRISE_TOKEN=fake-enterprise-token + +exec gh version +stderr 'Telemetry payload: none' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar new file mode 100644 index 00000000000..15e59fcf5e1 --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -0,0 +1,13 @@ +# The send-telemetry command should not itself generate a telemetry event +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 + +# Provide a minimal valid payload on stdin so the command can run. +# It will fail to connect but that's fine — we only care about telemetry logging. +stdin payload.json +! exec gh send-telemetry +stderr 'Telemetry payload: none' + +-- payload.json -- +{"events":[{"type":"test","dimensions":{},"measures":{}}]} diff --git a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar new file mode 100644 index 00000000000..14c4b67a6a8 --- /dev/null +++ b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar @@ -0,0 +1,7 @@ +# Command completes successfully even when telemetry endpoint is unreachable +env GH_TELEMETRY=enabled +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 + +exec gh version +stdout 'gh version' diff --git a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar new file mode 100644 index 00000000000..603dd2ae183 --- /dev/null +++ b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar @@ -0,0 +1,25 @@ +# Official extension stubs (the hidden commands suggesting installation of +# GitHub-owned extensions) are safe to report via telemetry: their command +# names come from a fixed, hard-coded registry and do not contain any +# user-authored identifiers. + +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# Ensure CI auto-install behavior does not kick in for this test; +# we want the non-TTY "print install instructions and exit non-zero" path. +env CI='' +env BUILD_NUMBER='' +env RUN_ID='' + +# `stack` is registered in extensions.OfficialExtensions. Since no real +# extension is installed, the hidden stub runs and, in a non-TTY session +# outside CI, prints install instructions and exits non-zero. +! exec gh stack +stderr 'gh extension install github/gh-stack' + +# The stub invocation records a command_invocation event for the stub's +# command path. +stderr 'Telemetry payload:' +stderr '"type": "command_invocation"' +stderr '"command": "gh stack"' diff --git a/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar new file mode 100644 index 00000000000..47978cf4dce --- /dev/null +++ b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar @@ -0,0 +1,70 @@ +# This test ensures that a malicious workflow which emit terminal control sequences (ESC, OSC, CSI) in +# its log output does not result in terminal injection when logs are displayed using `gh run view --log` + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow with escape sequences' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Run the workflow +exec gh workflow run 'Escape Sequence PoC' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to view +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# View the logs and check that raw ESC bytes (0x1b) are NOT present in output. +# If this assertion fails, it means terminal escape sequences from the workflow +# log are being passed through to the user's terminal unsanitised. +exec gh run view $RUN_ID --log + +# The output should contain the safe/visible text but not raw ESC bytes. +# \x1b is the ESC byte - it must not appear in the output. +! stdout '\x1b' + +# The log output should still contain the non-escape parts of the log lines. +stdout 'ESCAPE_MARKER_START' +stdout 'ESCAPE_MARKER_END' + +-- workflow.yml -- +name: Escape Sequence PoC + +on: + workflow_dispatch: + +jobs: + emit-escape-sequences: + runs-on: ubuntu-latest + steps: + - name: Emit terminal escape sequences + run: | + # OSC title set: \x1b]0;TITLE\x07 + printf 'ESCAPE_MARKER_START \033]0;HIJACKED_TITLE\007 ESCAPE_MARKER_END\n' + # CSI color: \x1b[31m ... \x1b[0m + printf 'ESCAPE_MARKER_START \033[31mRED_TEXT\033[0m ESCAPE_MARKER_END\n' + # Screen title set (from original PoC): \x1bk ... \x1b\\ + printf 'ESCAPE_MARKER_START \033k;malicious command;\033\\ ESCAPE_MARKER_END\n' diff --git a/api/export_pr.go b/api/export_pr.go index 9b030c39ed7..53a921e43ae 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -46,6 +46,71 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} { }) } data[f] = items + case "issueType": + data[f] = issue.IssueType + case "parent": + if issue.Parent != nil { + data[f] = map[string]interface{}{ + "id": issue.Parent.ID, + "number": issue.Parent.Number, + "title": issue.Parent.Title, + "url": issue.Parent.URL, + "state": issue.Parent.State, + } + } else { + data[f] = nil + } + case "subIssues": + items := make([]map[string]interface{}, 0, len(issue.SubIssues.Nodes)) + for _, n := range issue.SubIssues.Nodes { + items = append(items, map[string]interface{}{ + "id": n.ID, + "number": n.Number, + "title": n.Title, + "url": n.URL, + "state": n.State, + }) + } + data[f] = map[string]interface{}{ + "nodes": items, + "totalCount": issue.SubIssues.TotalCount, + } + case "subIssuesSummary": + data[f] = map[string]interface{}{ + "total": issue.SubIssuesSummary.Total, + "completed": issue.SubIssuesSummary.Completed, + "percentCompleted": issue.SubIssuesSummary.PercentCompleted, + } + case "blockedBy": + items := make([]map[string]interface{}, 0, len(issue.BlockedBy.Nodes)) + for _, n := range issue.BlockedBy.Nodes { + items = append(items, map[string]interface{}{ + "id": n.ID, + "number": n.Number, + "title": n.Title, + "url": n.URL, + "state": n.State, + }) + } + data[f] = map[string]interface{}{ + "nodes": items, + "totalCount": issue.BlockedBy.TotalCount, + } + case "blocking": + items := make([]map[string]interface{}, 0, len(issue.Blocking.Nodes)) + for _, n := range issue.Blocking.Nodes { + items = append(items, map[string]interface{}{ + "id": n.ID, + "number": n.Number, + "title": n.Title, + "url": n.URL, + "state": n.State, + }) + } + data[f] = map[string]interface{}{ + "nodes": items, + "totalCount": issue.Blocking.TotalCount, + } default: sf := fieldByName(v, f) data[f] = sf.Interface() diff --git a/api/export_pr_test.go b/api/export_pr_test.go index ec7b002498c..db12ed0bf3d 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -197,6 +197,209 @@ func TestIssue_ExportData(t *testing.T) { ] } `), }, + { + name: "issue type", + fields: []string{"issueType"}, + inputJSON: heredoc.Doc(` + { "issueType": { + "id": "IT_1", + "name": "Bug", + "description": "Something is not working", + "color": "d73a4a" + } } + `), + outputJSON: heredoc.Doc(` + { + "issueType": { + "id": "IT_1", + "name": "Bug", + "description": "Something is not working", + "color": "d73a4a" + } + } + `), + }, + { + name: "issue type null", + fields: []string{"issueType"}, + inputJSON: `{}`, + outputJSON: heredoc.Doc(` + { "issueType": null } + `), + }, + { + name: "parent", + fields: []string{"parent"}, + inputJSON: heredoc.Doc(` + { "parent": { + "id": "I_100", + "number": 100, + "title": "Epic: Authentication overhaul", + "url": "https://github.com/OWNER/REPO/issues/100", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } } + `), + outputJSON: heredoc.Doc(` + { + "parent": { + "id": "I_100", + "number": 100, + "title": "Epic: Authentication overhaul", + "url": "https://github.com/OWNER/REPO/issues/100", + "state": "OPEN" + } + } + `), + }, + { + name: "parent null", + fields: []string{"parent"}, + inputJSON: `{}`, + outputJSON: heredoc.Doc(` + { "parent": null } + `), + }, + { + name: "sub-issues", + fields: []string{"subIssues"}, + inputJSON: heredoc.Doc(` + { "subIssues": { + "nodes": [ + { + "id": "I_101", + "number": 101, + "title": "Design auth module", + "url": "https://github.com/OWNER/REPO/issues/101", + "state": "CLOSED", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "id": "I_102", + "number": 102, + "title": "Token refresh logic", + "url": "https://github.com/OWNER/REPO/issues/102", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } + ], + "totalCount": 2 + } } + `), + outputJSON: heredoc.Doc(` + { + "subIssues": { + "nodes": [ + { + "id": "I_101", + "number": 101, + "title": "Design auth module", + "url": "https://github.com/OWNER/REPO/issues/101", + "state": "CLOSED" + }, + { + "id": "I_102", + "number": 102, + "title": "Token refresh logic", + "url": "https://github.com/OWNER/REPO/issues/102", + "state": "OPEN" + } + ], + "totalCount": 2 + } + } + `), + }, + { + name: "sub-issues summary", + fields: []string{"subIssuesSummary"}, + inputJSON: heredoc.Doc(` + { "subIssuesSummary": { + "total": 4, + "completed": 1, + "percentCompleted": 25.0 + } } + `), + outputJSON: heredoc.Doc(` + { + "subIssuesSummary": { + "total": 4, + "completed": 1, + "percentCompleted": 25 + } + } + `), + }, + { + name: "blocked by", + fields: []string{"blockedBy"}, + inputJSON: heredoc.Doc(` + { "blockedBy": { + "nodes": [ + { + "id": "I_200", + "number": 200, + "title": "API rate limiting", + "url": "https://github.com/OWNER/REPO/issues/200", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } + ], + "totalCount": 1 + } } + `), + outputJSON: heredoc.Doc(` + { + "blockedBy": { + "nodes": [ + { + "id": "I_200", + "number": 200, + "title": "API rate limiting", + "url": "https://github.com/OWNER/REPO/issues/200", + "state": "OPEN" + } + ], + "totalCount": 1 + } + } + `), + }, + { + name: "blocking", + fields: []string{"blocking"}, + inputJSON: heredoc.Doc(` + { "blocking": { + "nodes": [ + { + "id": "I_300", + "number": 300, + "title": "Release v2.0", + "url": "https://github.com/OWNER/REPO/issues/300", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } + ], + "totalCount": 1 + } } + `), + outputJSON: heredoc.Doc(` + { + "blocking": { + "nodes": [ + { + "id": "I_300", + "number": 300, + "title": "Release v2.0", + "url": "https://github.com/OWNER/REPO/issues/300", + "state": "OPEN" + } + ], + "totalCount": 1 + } + } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/api/http_client.go b/api/http_client.go index 532f79c7f9d..078a2a86c8a 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/utils" ghAPI "github.com/cli/go-gh/v2/pkg/api" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -26,6 +27,7 @@ type HTTPClientOptions struct { LogColorize bool LogVerboseHTTP bool SkipDefaultHeaders bool + TelemetryDisabler ghtelemetry.Disabler } func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { @@ -74,6 +76,57 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { client.Transport = AddAuthTokenHeader(client.Transport, opts.Config) } + if opts.TelemetryDisabler != nil { + client.Transport = telemetryDisablerTransport{ + wrappedTransport: client.Transport, + telemetryDisabler: opts.TelemetryDisabler, + } + } + + return client, nil +} + +// ExternalHTTPClientOptions holds options for creating an external HTTP client. +type ExternalHTTPClientOptions struct { + AppVersion string + Log io.Writer + LogColorize bool + Transport http.RoundTripper +} + +// NewExternalHTTPClient creates an HTTP client for talking to non-GitHub hosts. +// It includes debug logging and a User-Agent header but does not attach any +// authentication tokens or GitHub-specific headers. +func NewExternalHTTPClient(opts ExternalHTTPClientOptions) (*http.Client, error) { + clientOpts := ghAPI.ClientOptions{ + Host: "none", + AuthToken: "none", + LogIgnoreEnv: true, + SkipDefaultHeaders: true, + Transport: opts.Transport, + } + + debugEnabled, debugValue := utils.IsDebugEnabled() + logVerboseHTTP := false + if strings.Contains(debugValue, "api") { + logVerboseHTTP = true + } + + if logVerboseHTTP || debugEnabled { + clientOpts.Log = opts.Log + clientOpts.LogColorize = opts.LogColorize + clientOpts.LogVerboseHTTP = logVerboseHTTP + } + + clientOpts.Headers = map[string]string{ + userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion), + } + + client, err := ghAPI.NewHTTPClient(clientOpts) + if err != nil { + return nil, err + } + return client, nil } @@ -83,7 +136,7 @@ func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Clien return &newClient } -// AddCacheTTLHeader adds an header to the request telling the cache that the request +// AddCacheTTLHeader adds a header to the request telling the cache that the request // should be cached for a specified amount of time. func AddCacheTTLHeader(rt http.RoundTripper, ttl time.Duration) http.RoundTripper { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { @@ -147,3 +200,15 @@ func getHost(r *http.Request) string { } return r.URL.Host } + +type telemetryDisablerTransport struct { + wrappedTransport http.RoundTripper + telemetryDisabler ghtelemetry.Disabler +} + +func (t telemetryDisablerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if ghauth.IsEnterprise(getHost(req)) { + t.telemetryDisabler.Disable() + } + return t.wrappedTransport.RoundTrip(req) +} diff --git a/api/http_client_test.go b/api/http_client_test.go index 1c81b4aa7a5..56be00af6b0 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -315,6 +315,129 @@ func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) { assert.Equal(t, "monalisa¡", issue.Author.Login) } +func TestNewHTTPClientTelemetryDisabler(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + tests := []struct { + name string + host string + wantDisabled bool + }{ + { + name: "enterprise host triggers disable", + host: "ghes.example.com", + wantDisabled: true, + }, + { + name: "github.com does not trigger disable", + host: "github.com", + wantDisabled: false, + }, + { + name: "tenancy host does not trigger disable", + host: "my-company.ghe.com", + wantDisabled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + disabler := &fakeTelemetryDisabler{} + client, err := NewHTTPClient(HTTPClientOptions{ + TelemetryDisabler: disabler, + }) + require.NoError(t, err) + + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + req.Host = tt.host + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) + assert.Equal(t, tt.wantDisabled, disabler.disabled, "Disable() called") + }) + } +} + +func TestNewHTTPClientWithoutTelemetryDisabler(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + client, err := NewHTTPClient(HTTPClientOptions{}) + require.NoError(t, err) + + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + req.Host = "ghes.example.com" + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) +} + +func TestNewExternalHTTPClient(t *testing.T) { + tests := []struct { + name string + url string + }{ + { + name: "third-party host", + url: "https://example.com/path", + }, + { + // Even when talking to GitHub, the external client must not set + // authorization or any GitHub-specific headers. + name: "github.com host", + url: "https://api.github.com/repos/cli/cli", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotReq *http.Request + transport := &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { + gotReq = req + return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader(""))}, nil + }} + + client, err := NewExternalHTTPClient(ExternalHTTPClientOptions{ + AppVersion: "v1.2.3", + Transport: transport, + }) + require.NoError(t, err) + + req, err := http.NewRequest("GET", tt.url, nil) + require.NoError(t, err) + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) + + // No headers should be set by default, except for User-Agent which should include the app version. + assert.Equal(t, []string{"GitHub CLI v1.2.3"}, gotReq.Header.Values("user-agent")) + assert.Empty(t, gotReq.Header.Values("authorization")) + assert.Empty(t, gotReq.Header.Values("x-github-api-version")) + assert.Empty(t, gotReq.Header.Values("accept")) + assert.Empty(t, gotReq.Header.Values("content-type")) + assert.Empty(t, gotReq.Header.Values("time-zone")) + }) + } +} + +type fakeTelemetryDisabler struct { + disabled bool +} + +func (f *fakeTelemetryDisabler) Disable() { + f.disabled = true +} + type tinyConfig map[string]string func (c tinyConfig) ActiveToken(host string) (string, string) { diff --git a/api/queries_issue.go b/api/queries_issue.go index bff84029dc0..a13bb48cc84 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -2,10 +2,13 @@ package api import ( "encoding/json" + "errors" "fmt" + "sync" "time" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/shurcooL/githubv4" ) type IssuesPayload struct { @@ -46,9 +49,55 @@ type Issue struct { ReactionGroups ReactionGroups IsPinned bool + IssueType *IssueType + Parent *LinkedIssue + SubIssues SubIssues + SubIssuesSummary SubIssuesSummary + BlockedBy LinkedIssueConnection + Blocking LinkedIssueConnection + ClosedByPullRequestsReferences ClosedByPullRequestsReferences } +// IssueType represents an issue type configured for a repository. +type IssueType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` +} + +// LinkedIssue represents a related issue (parent, sub-issue, or relationship target). +type LinkedIssue struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + State string `json:"state"` + Repository struct { + NameWithOwner string `json:"nameWithOwner"` + } `json:"repository"` +} + +// SubIssues is a connection of sub-issues with a total count. +type SubIssues struct { + Nodes []LinkedIssue `json:"nodes"` + TotalCount int `json:"totalCount"` +} + +// SubIssuesSummary contains completion stats for sub-issues. +type SubIssuesSummary struct { + Total int `json:"total"` + Completed int `json:"completed"` + PercentCompleted float64 `json:"percentCompleted"` +} + +// LinkedIssueConnection is a connection of related issues (blocked-by or blocking). +type LinkedIssueConnection struct { + Nodes []LinkedIssue `json:"nodes"` + TotalCount int `json:"totalCount"` +} + type ClosedByPullRequestsReferences struct { Nodes []struct { ID string @@ -431,3 +480,302 @@ func (i Issue) Identifier() string { func (i Issue) CurrentUserComments() []Comment { return i.Comments.CurrentUserComments() } + +// UpdateIssueIssueType sets or clears the issue type on an issue. Pass an +// empty issueTypeID to clear the issue type. +func UpdateIssueIssueType(client *Client, hostname string, issueID string, issueTypeID string) error { + type UpdateIssueIssueTypeInput struct { + IssueID githubv4.ID `json:"issueId"` + IssueTypeID *githubv4.ID `json:"issueTypeId"` + } + + var mutation struct { + UpdateIssueIssueType struct { + Issue struct { + ID string + } + } `graphql:"updateIssueIssueType(input: $input)"` + } + + var typeID *githubv4.ID + if issueTypeID != "" { + id := githubv4.ID(issueTypeID) + typeID = &id + } + + variables := map[string]interface{}{ + "input": UpdateIssueIssueTypeInput{ + IssueID: githubv4.ID(issueID), + IssueTypeID: typeID, + }, + } + + return client.Mutate(hostname, "UpdateIssueIssueType", &mutation, variables) +} + +// AddSubIssue adds a sub-issue to a parent issue. +func AddSubIssue(client *Client, hostname string, parentID string, subIssueID string, replaceParent bool) error { + type AddSubIssueInput struct { + IssueID githubv4.ID `json:"issueId"` + SubIssueID githubv4.ID `json:"subIssueId"` + ReplaceParent githubv4.Boolean `json:"replaceParent"` + } + + var mutation struct { + AddSubIssue struct { + Issue struct { + ID string + } + } `graphql:"addSubIssue(input: $input)"` + } + + variables := map[string]interface{}{ + "input": AddSubIssueInput{ + IssueID: githubv4.ID(parentID), + SubIssueID: githubv4.ID(subIssueID), + ReplaceParent: githubv4.Boolean(replaceParent), + }, + } + + return client.Mutate(hostname, "AddSubIssue", &mutation, variables) +} + +// RemoveSubIssue removes a sub-issue from a parent issue. +func RemoveSubIssue(client *Client, hostname string, parentID string, subIssueID string) error { + type RemoveSubIssueInput struct { + IssueID githubv4.ID `json:"issueId"` + SubIssueID githubv4.ID `json:"subIssueId"` + } + + var mutation struct { + RemoveSubIssue struct { + Issue struct { + ID string + } + } `graphql:"removeSubIssue(input: $input)"` + } + + variables := map[string]interface{}{ + "input": RemoveSubIssueInput{ + IssueID: githubv4.ID(parentID), + SubIssueID: githubv4.ID(subIssueID), + }, + } + + return client.Mutate(hostname, "RemoveSubIssue", &mutation, variables) +} + +// AddBlockedBy marks an issue as blocked by another issue. +func AddBlockedBy(client *Client, hostname string, issueID string, blockingIssueID string) error { + type AddBlockedByInput struct { + IssueID githubv4.ID `json:"issueId"` + BlockingIssueID githubv4.ID `json:"blockingIssueId"` + } + + var mutation struct { + AddBlockedBy struct { + Issue struct { + ID string + } + } `graphql:"addBlockedBy(input: $input)"` + } + + variables := map[string]interface{}{ + "input": AddBlockedByInput{ + IssueID: githubv4.ID(issueID), + BlockingIssueID: githubv4.ID(blockingIssueID), + }, + } + + return client.Mutate(hostname, "AddBlockedBy", &mutation, variables) +} + +// RemoveBlockedBy removes a "blocked by" relationship between two issues. +func RemoveBlockedBy(client *Client, hostname string, issueID string, blockingIssueID string) error { + type RemoveBlockedByInput struct { + IssueID githubv4.ID `json:"issueId"` + BlockingIssueID githubv4.ID `json:"blockingIssueId"` + } + + var mutation struct { + RemoveBlockedBy struct { + Issue struct { + ID string + } + } `graphql:"removeBlockedBy(input: $input)"` + } + + variables := map[string]interface{}{ + "input": RemoveBlockedByInput{ + IssueID: githubv4.ID(issueID), + BlockingIssueID: githubv4.ID(blockingIssueID), + }, + } + + return client.Mutate(hostname, "RemoveBlockedBy", &mutation, variables) +} + +// DeferredUpdateIssueOptions updates an issue with mutations unsupported by the +// standard issue update mutations. All ID fields are node IDs. +type DeferredUpdateIssueOptions struct { + IssueID string + Hostname string + + IssueTypeID string + RemoveIssueType bool + + ParentID string + ReplaceExistingParent bool + RemoveParentID string + + AddSubIssueIDs []string + RemoveSubIssueIDs []string + + AddBlockedByIDs []string + RemoveBlockedByIDs []string + + // AddBlockingIDs / RemoveBlockingIDs name issues that this issue + // blocks. They are applied via the addBlockedBy / removeBlockedBy + // mutations with the arguments swapped. + AddBlockingIDs []string + RemoveBlockingIDs []string +} + +// DeferredUpdateIssue runs issue mutations described by opts in +// parallel and returns any failures as a single joined error so a single +// failure does not abort the rest. +func DeferredUpdateIssue(client *Client, opts DeferredUpdateIssueOptions) error { + var mutations []func() error + + if opts.IssueTypeID != "" || opts.RemoveIssueType { + mutations = append(mutations, func() error { + return UpdateIssueIssueType(client, opts.Hostname, opts.IssueID, opts.IssueTypeID) + }) + } + + if opts.ParentID != "" { + mutations = append(mutations, func() error { + return AddSubIssue(client, opts.Hostname, opts.ParentID, opts.IssueID, opts.ReplaceExistingParent) + }) + } else if opts.RemoveParentID != "" { + mutations = append(mutations, func() error { + return RemoveSubIssue(client, opts.Hostname, opts.RemoveParentID, opts.IssueID) + }) + } + + for _, id := range opts.AddSubIssueIDs { + mutations = append(mutations, func() error { + return AddSubIssue(client, opts.Hostname, opts.IssueID, id, true) + }) + } + for _, id := range opts.RemoveSubIssueIDs { + mutations = append(mutations, func() error { + return RemoveSubIssue(client, opts.Hostname, opts.IssueID, id) + }) + } + + for _, id := range opts.AddBlockedByIDs { + mutations = append(mutations, func() error { + return AddBlockedBy(client, opts.Hostname, opts.IssueID, id) + }) + } + for _, id := range opts.RemoveBlockedByIDs { + mutations = append(mutations, func() error { + return RemoveBlockedBy(client, opts.Hostname, opts.IssueID, id) + }) + } + + for _, id := range opts.AddBlockingIDs { + mutations = append(mutations, func() error { + // blocking is the inverse of blocked-by: this issue blocks `id`, + // expressed as `id` is blocked by this issue. + return AddBlockedBy(client, opts.Hostname, id, opts.IssueID) + }) + } + for _, id := range opts.RemoveBlockingIDs { + mutations = append(mutations, func() error { + return RemoveBlockedBy(client, opts.Hostname, id, opts.IssueID) + }) + } + + if len(mutations) == 0 { + return nil + } + + errCh := make(chan error, len(mutations)) + var wg sync.WaitGroup + for _, m := range mutations { + wg.Add(1) + go func(m func() error) { + defer wg.Done() + if err := m(); err != nil { + errCh <- err + } + }(m) + } + wg.Wait() + close(errCh) + + var errs []error + for err := range errCh { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +// RepoIssueTypes fetches the available issue types for a repository. +func RepoIssueTypes(client *Client, repo ghrepo.Interface) ([]IssueType, error) { + query := ` + query RepositoryIssueTypes($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issueTypes(first: 50) { + nodes { id, name, description, color } + } + } + }` + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + var result struct { + Repository struct { + IssueTypes struct { + Nodes []IssueType + } + } + } + err := client.GraphQL(repo.RepoHost(), query, variables, &result) + if err != nil { + return nil, err + } + return result.Repository.IssueTypes.Nodes, nil +} + +// IssueNodeID fetches the node ID for an issue given its number and repository. +func IssueNodeID(client *Client, repo ghrepo.Interface, number int) (string, error) { + query := ` + query IssueNodeID($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + id + } + } + }` + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + "number": number, + } + var result struct { + Repository struct { + Issue struct { + ID string + } + } + } + err := client.GraphQL(repo.RepoHost(), query, variables, &result) + if err != nil { + return "", err + } + return result.Repository.Issue.ID, nil +} diff --git a/api/queries_projects_v2.go b/api/queries_projects_v2.go index 0126c1caa3b..b5f46655d7b 100644 --- a/api/queries_projects_v2.go +++ b/api/queries_projects_v2.go @@ -9,12 +9,13 @@ import ( ) const ( - errorProjectsV2ReadScope = "field requires one of the following scopes: ['read:project']" - errorProjectsV2UserField = "Field 'projectsV2' doesn't exist on type 'User'" - errorProjectsV2RepositoryField = "Field 'projectsV2' doesn't exist on type 'Repository'" - errorProjectsV2OrganizationField = "Field 'projectsV2' doesn't exist on type 'Organization'" - errorProjectsV2IssueField = "Field 'projectItems' doesn't exist on type 'Issue'" - errorProjectsV2PullRequestField = "Field 'projectItems' doesn't exist on type 'PullRequest'" + errorProjectsV2ReadScope = "field requires one of the following scopes: ['read:project']" + errorProjectsV2UserField = "Field 'projectsV2' doesn't exist on type 'User'" + errorProjectsV2RepositoryField = "Field 'projectsV2' doesn't exist on type 'Repository'" + errorProjectsV2OrganizationField = "Field 'projectsV2' doesn't exist on type 'Organization'" + errorProjectsV2IssueField = "Field 'projectItems' doesn't exist on type 'Issue'" + errorProjectsV2PullRequestField = "Field 'projectItems' doesn't exist on type 'PullRequest'" + errorProjectsV2ResourceNotAccessible = "Resource not accessible by" ) type ProjectV2 struct { @@ -321,10 +322,11 @@ func CurrentUserProjectsV2(client *Client, hostname string) ([]ProjectV2, error) } // When querying ProjectsV2 fields we generally don't want to show the user -// scope errors and field does not exist errors. ProjectsV2IgnorableError -// checks against known error strings to see if an error can be safely ignored. -// Due to the fact that the GraphQLClient can return multiple types of errors -// this uses brittle string comparison to check against the known error strings. +// scope errors, field does not exist errors, or authorization errors. +// ProjectsV2IgnorableError checks against known error strings to see if an +// error can be safely ignored. Due to the fact that the GraphQLClient can +// return multiple types of errors this uses brittle string comparison to check +// against the known error strings. func ProjectsV2IgnorableError(err error) bool { msg := err.Error() if strings.Contains(msg, errorProjectsV2ReadScope) || @@ -332,7 +334,8 @@ func ProjectsV2IgnorableError(err error) bool { strings.Contains(msg, errorProjectsV2RepositoryField) || strings.Contains(msg, errorProjectsV2OrganizationField) || strings.Contains(msg, errorProjectsV2IssueField) || - strings.Contains(msg, errorProjectsV2PullRequestField) { + strings.Contains(msg, errorProjectsV2PullRequestField) || + strings.Contains(msg, errorProjectsV2ResourceNotAccessible) { return true } return false diff --git a/api/queries_projects_v2_test.go b/api/queries_projects_v2_test.go index 3d29a19c144..1f1d91b8295 100644 --- a/api/queries_projects_v2_test.go +++ b/api/queries_projects_v2_test.go @@ -317,6 +317,21 @@ func TestProjectsV2IgnorableError(t *testing.T) { errMsg: "Field 'projectItems' doesn't exist on type 'PullRequest'", expectOut: true, }, + { + name: "resource not accessible by integration", + errMsg: "Resource not accessible by integration", + expectOut: true, + }, + { + name: "resource not accessible by personal access token", + errMsg: "Resource not accessible by personal access token", + expectOut: true, + }, + { + name: "resource not accessible by integration with path context", + errMsg: "GraphQL: Resource not accessible by integration (repository.pullRequest.projectItems.nodes.0)", + expectOut: true, + }, { name: "other error", errMsg: "some other graphql error message", diff --git a/api/query_builder.go b/api/query_builder.go index 9c97e67e997..d988cdc67a5 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -340,6 +340,12 @@ var issueOnlyFields = []string{ "isPinned", "stateReason", "closedByPullRequestsReferences", + "issueType", + "parent", + "subIssues", + "subIssuesSummary", + "blockedBy", + "blocking", } var IssueFields = append(sharedIssuePRFields, issueOnlyFields...) @@ -436,6 +442,18 @@ func IssueGraphQL(fields []string) string { q = append(q, prClosingIssuesReferences) case "closedByPullRequestsReferences": q = append(q, issueClosedByPullRequestsReferences) + case "issueType": + q = append(q, `issueType{id,name,description,color}`) + case "parent": + q = append(q, `parent{id,number,title,url,state,repository{nameWithOwner}}`) + case "subIssues": + q = append(q, `subIssues(first:100){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`) + case "subIssuesSummary": + q = append(q, `subIssuesSummary{total,completed,percentCompleted}`) + case "blockedBy": + q = append(q, `blockedBy(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`) + case "blocking": + q = append(q, `blocking(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`) default: q = append(q, field) } diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 60fd8af58d6..cb76f422087 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/docs" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" @@ -53,7 +54,7 @@ func run(args []string) error { return config.NewFromString(""), nil }, ExtensionManager: &em{}, - }, "", "") + }, &telemetry.NoOpService{}, "", "") rootCmd.InitDefaultHelpCmd() if err := os.MkdirAll(*dir, 0755); err != nil { diff --git a/docs/install_linux.md b/docs/install_linux.md index bda751a7a82..3d2f2980fec 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -17,7 +17,7 @@ > - `https://cli.github.com/packages/githubcli-archive-keyring.gpg` (Binary): > ``` > SHA256: 6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b -> SHA512: ce6b9466dbd2a90b3227e177aa9b8187bd2405b1c29f91d78de83b9699dbbe2af35efd733bf53da622e7a38c59a7bc55539d63a3deaec9ff9c2bff8af626434 +> SHA512: ce6b9466dbd2a90b3227e177aa9b8187bd2405b1c29f91d78de83b9699dbbe2af35efd733bf53da622e7a38c59a7bc55539d63a3deae3c9ff9c2bff8af626434 > MD5: 23748c0965069fb1edae1b83c17890e1 > ``` > - `https://cli.github.com/packages/githubcli-archive-keyring.asc` (ASCII-armored): @@ -100,7 +100,7 @@ To install: ```bash sudo dnf install dnf5-plugins sudo dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo -sudo dnf install gh --repo gh-cli +sudo dnf install gh ``` To upgrade: @@ -119,7 +119,7 @@ To install: ```bash sudo dnf install 'dnf-command(config-manager)' sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo -sudo dnf install gh --repo gh-cli +sudo dnf install gh ``` To upgrade: @@ -165,7 +165,7 @@ sudo zypper update gh [Homebrew](https://brew.sh/) is a free and open-source software package management system that simplifies the installation of software on Apple's operating system, macOS, as well as Linux. -The [GitHub CLI formulae](https://formulae.brew.sh/formula/gh) is supported by the GitHub CLI maintainers with help from our friends at Homebrew with updated powered by [homebrew/hoomebrew-core](https://github.com/Homebrew/homebrew-core/blob/main/Formula/g/gh.rb). +The [GitHub CLI formulae](https://formulae.brew.sh/formula/gh) is supported by the GitHub CLI maintainers with help from our friends at Homebrew with updates powered by [homebrew/hoomebrew-core](https://github.com/Homebrew/homebrew-core/blob/main/Formula/g/gh.rb). To install: diff --git a/docs/install_windows.md b/docs/install_windows.md index 88ddc99b8d3..508afca3f16 100644 --- a/docs/install_windows.md +++ b/docs/install_windows.md @@ -11,13 +11,13 @@ The [GitHub CLI package](https://winget.run/pkg/GitHub/cli) is supported by Micr To install: ```pwsh -winget install --id GitHub.cli +winget install --id GitHub.cli --source winget ``` To upgrade: ```pwsh -winget upgrade --id GitHub.cli +winget upgrade --id GitHub.cli --source winget ``` > [!NOTE] diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md index 31f44f6efd2..31c15a9acd5 100644 --- a/docs/release-process-deep-dive.md +++ b/docs/release-process-deep-dive.md @@ -11,7 +11,6 @@ From a high level, the [release workflow](https://github.com/cli/cli/blob/537a22 * Builds and updates the [manual](https://cli.github.com/manual) and repository packages * Creates GitHub Attestations for the artifacts * Creates a GitHub Release and attaches the artifacts - * Bumps the `gh` [homebrew-core formula](https://github.com/Homebrew/homebrew-core/blob/2df031cbd8f7bc9b9a380e941ccefcf3c8f3d02b/Formula/g/gh.rb) # Jobs Deep Dive @@ -495,7 +494,7 @@ release: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: "dist/gh_*" - name: Run createrepo @@ -569,16 +568,6 @@ release: git log --oneline @{upstream}.. git diff --name-status @{upstream}.. fi - - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@v3 - if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') - with: - formula-name: gh - formula-path: Formula/g/gh.rb - tag-name: ${{ inputs.tag_name }} - push-to: williammartin/homebrew-core - env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} ``` @@ -647,11 +636,11 @@ In previous steps, a git commit was made for the manual, and files had moved int Occasionally, the repository can become unwieldy due to hosting so many large binary artifacts. Instructions can be found in the README for that repository. -#### Homebrew Formula +#### Homebrew -Using [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-homebrew-formula-action), a PR for the `gh` [`homebrew-core` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb) is created. The fork repository is currently owned by `williammartin` as PRs are [not accepted from organizations.](https://github.com/cli/cli/pull/7953) +Historically, we used [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-homebrew-formula-action). It created a PR for the `gh` [`homebrew-core` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb). The fork repository was owned by `williammartin` because PRs are [not accepted from organizations.](https://github.com/cli/cli/pull/7953) -`Homebrew/formulae.brew.sh` makes new formula versions available every 15 minutes through scheduled CI workflow. For more information, see https://docs.brew.sh/Formula-Cookbook#an-introduction +However, since this required a legacy PAT token to open a PR between these repositories, it was deemed too much risk for our security. As such, we now rely on [Homebrew's autobump](https://docs.brew.sh/Autobump). ## Deepest Dive @@ -659,7 +648,7 @@ Using [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-hom [`./script/release`](https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/script/release) is used by `gh` maintainers to [create a new release](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/docs/releasing.md). When invoked it executes `gh workflow run` in order to kick off the workflow described in detail above. However, that workflow also calls back into `./script/release` with the `--local` flag resulting in release artifacts being created on the machine invoking it. Each OS specific job in the workflow additionally provides the `--platform` flag. -The surprising behaviour in `./script/release` is that it uses `sed` to modify the base [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) file, so that only platform specific sections are retained. For example, in the case of of `linux` only the [`linux` build](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L27) and [`npmfs`](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L78) section would be configured for `GoReleaser`. The `archive` sections are addressed by [requirements](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L52) on previous platform builds. +The surprising behaviour in `./script/release` is that it uses `sed` to modify the base [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) file, so that only platform specific sections are retained. For example, in the case of `linux` only the [`linux` build](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L27) and [`npmfs`](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L78) section would be configured for `GoReleaser`. The `archive` sections are addressed by [requirements](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L52) on previous platform builds. Each build entry in [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) specifies the platforms that are supported, for example: diff --git a/docs/releasing.md b/docs/releasing.md index b424266d4ff..9f304699127 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -21,13 +21,14 @@ What this does is: - Uploads all release artifacts to a new GitHub Release; - A new git tag `vX.Y.Z` is created in the remote repository; - The changelog is [generated from the list of merged pull requests](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes); -- Updates [GitHub CLI marketing site](https://cli.github.com) with the contents of the new release; -- Updates the [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) in the [`homebrew/homebrew-core` repo](https://github.com/search?q=repo%3AHomebrew%2Fhomebrew-core+%22gh%22+in%3Atitle&type=pullrequests). +- Updates [GitHub CLI marketing site](https://cli.github.com) with the contents of the new release. -> [!NOTE] -> `Homebrew/formulae.brew.sh` makes new formula versions available every 15 minutes through scheduled [CI workflow](https://github.com/Homebrew/formulae.brew.sh/actions/workflows/tests.yml). -> -> For more information, see https://docs.brew.sh/Formula-Cookbook#an-introduction +## Bumping Homebrew + +Homebrew bumps are handled by [autobump](https://docs.brew.sh/Autobump), which runs periodically every 3 hours. In cases where a quicker rollout is required, a pull request can be opened manually with the following steps: + 1. Replace the version number in the url to point ot the updated version. + 2. Calculate and replace the sha256 value. + 3. Open the PR. To test out the build system while avoiding creating an actual release: @@ -60,6 +61,5 @@ Occasionally, it might be necessary to clean up a bad release and re-release. 1. Delete the release and associated tag 2. Re-release and monitor the workflow run logs -3. Open pull request updating [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) - with new SHA versions, linking the previous PR +3. Open pull request updating [`gh` Homebrew formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb) with new SHA versions, linking the previous PR 4. Verify resulting Debian and RPM packages, Homebrew formula diff --git a/git/client.go b/git/client.go index 5f547c99c41..9f6670d6200 100644 --- a/git/client.go +++ b/git/client.go @@ -106,7 +106,7 @@ func (c *Client) Command(ctx context.Context, args ...string) (*Command, error) // It is only usable when constructed by another function in the package because the empty pattern, // without allMatching set to true, will result in an error in AuthenticatedCommand. // -// Callers can currently opt-in to an slightly less secure mode for backwards compatibility by using +// Callers can currently opt-in to a slightly less secure mode for backwards compatibility by using // AllMatchingCredentialsPattern. type CredentialPattern struct { allMatching bool // should only be constructable via AllMatchingCredentialsPattern @@ -713,6 +713,47 @@ func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) { return true, nil } +// RemoteURL returns the fetch URL configured for the named remote. +func (c *Client) RemoteURL(ctx context.Context, name string) (string, error) { + cmd, err := c.Command(ctx, "remote", "get-url", "--", name) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return firstLine(out), nil +} + +// IsIgnored reports whether the given path is ignored by .gitignore rules. +// Returns an error for fatal git failures (e.g. path outside repository). +func (c *Client) IsIgnored(ctx context.Context, path string) (bool, error) { + cmd, err := c.Command(ctx, "check-ignore", "-q", "--", path) + if err != nil { + return false, err + } + _, err = cmd.Output() + if err == nil { + return true, nil + } + // Exit 1 here means we can confirm the path is not ignored. + // Any other error is a real git error. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err +} + +// ShortSHA returns the first 8 characters of a SHA hash for display purposes. +func ShortSHA(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} + func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error { args := []string{"config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name)} cmd, err := c.Command(ctx, args...) diff --git a/git/client_test.go b/git/client_test.go index f59b2607713..7ffee2dc93c 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -2164,3 +2164,123 @@ func createMockedCommandContext(t *testing.T, commands mockedCommands) commandCt return cmd } } + +func TestClientRemoteURL(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantURL string + wantErrorMsg string + }{ + { + name: "returns remote URL", + cmdStdout: "https://github.com/monalisa/skills-repo.git\n", + wantCmdArgs: "path/to/git remote get-url -- origin", + wantURL: "https://github.com/monalisa/skills-repo.git", + }, + { + name: "git error", + cmdExitStatus: 1, + cmdStderr: "fatal: No such remote 'nonexistent'", + wantCmdArgs: "path/to/git remote get-url -- nonexistent", + wantErrorMsg: "failed to run git: fatal: No such remote 'nonexistent'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + remoteName := "origin" + if tt.wantErrorMsg != "" { + remoteName = "nonexistent" + } + url, err := client.RemoteURL(context.Background(), remoteName) + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + if tt.wantErrorMsg == "" { + assert.NoError(t, err) + assert.Equal(t, tt.wantURL, url) + } else { + assert.EqualError(t, err, tt.wantErrorMsg) + } + }) + } + + // Covers the early return in RemoteURL when Command() itself fails. + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + _, err := client.RemoteURL(context.Background(), "origin") + assert.Error(t, err) + }) +} + +func TestClientIsIgnored(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantIgnored bool + wantErr bool + }{ + { + name: "path is ignored", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: true, + }, + { + name: "path is not ignored", + cmdExitStatus: 1, + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + }, + { + name: "fatal git error", + cmdExitStatus: 128, + cmdStderr: "fatal: not a git repository", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + assert.Equal(t, tt.wantIgnored, ignored) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + + // Covers the early return in IsIgnored when Command() itself fails + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.False(t, ignored) + assert.Error(t, err) + }) +} + +func TestShortSHA(t *testing.T) { + assert.Equal(t, "abc123de", ShortSHA("abc123def456789")) + assert.Equal(t, "short", ShortSHA("short")) +} diff --git a/go.mod b/go.mod index 615b1ebf404..a44260260d8 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/cli/cli/v2 -go 1.26.1 +go 1.26.0 -toolchain go1.26.2 +toolchain go1.26.4 require ( charm.land/bubbles/v2 v2.1.0 - charm.land/bubbletea/v2 v2.0.2 + charm.land/bubbletea/v2 v2.0.7 charm.land/huh/v2 v2.0.3 - charm.land/lipgloss/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.3 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 @@ -27,19 +27,20 @@ require ( github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea github.com/distribution/reference v0.6.0 github.com/gabriel-vasile/mimetype v1.4.13 - github.com/gdamore/tcell/v2 v2.13.8 + github.com/gdamore/tcell/v2 v2.13.10 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.21.4 + github.com/google/go-containerregistry v0.21.6 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-version v1.9.0 github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec github.com/in-toto/attestation v1.2.0 github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/klauspost/compress v1.18.5 - github.com/mattn/go-colorable v0.1.14 - github.com/mattn/go-isatty v0.0.20 + github.com/klauspost/compress v1.18.6 + github.com/mattn/go-colorable v0.1.15 + github.com/mattn/go-isatty v0.0.22 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/microsoft/dev-tunnels v0.1.19 github.com/muhammadmuzzammil1998/jsonc v1.0.0 @@ -51,15 +52,17 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/theupdateframework/go-tuf/v2 v2.4.1 + github.com/theupdateframework/go-tuf/v2 v2.4.2 + github.com/twitchtv/twirp v8.1.3+incompatible github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.52.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.0 - google.golang.org/grpc v1.80.0 + golang.org/x/sys v0.45.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 @@ -77,9 +80,9 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect @@ -91,13 +94,12 @@ require ( github.com/cli/shurcooL-graphql v0.0.4 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/cli v29.3.1+incompatible // indirect + github.com/docker/cli v29.4.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect @@ -128,7 +130,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/certificate-transparency-go v1.3.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect @@ -139,11 +140,10 @@ require ( github.com/itchyny/gojq v0.12.17 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -157,13 +157,13 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rodaine/table v1.3.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/sigstore/rekor v1.5.0 // indirect github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect - github.com/sigstore/sigstore v1.10.5 // indirect + github.com/sigstore/sigstore v1.10.6 // indirect github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -171,19 +171,17 @@ require ( github.com/thlib/go-timezone-local v0.0.6 // indirect github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect github.com/transparency-dev/merkle v0.0.2 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect gotest.tools/v3 v3.5.2 // indirect diff --git a/go.sum b/go.sum index b6e6f64ed51..4e52b7587a3 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= -charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= -charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= @@ -110,16 +110,16 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= @@ -162,8 +162,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -189,8 +187,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v29.3.1+incompatible h1:M04FDj2TRehDacrosh7Vlkgc7AuQoWloQkf1PA5hmoI= -github.com/docker/cli v29.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= +github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -205,8 +203,8 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= -github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= +github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -279,8 +277,8 @@ github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCY github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.4 h1:VrhlIQtdhE6riZW//MjPrcJ1snAjPoCCpPHqGOygrv8= -github.com/google/go-containerregistry v0.21.4/go.mod h1:kxgc23zQ2qMY/hAKt0wCbB/7tkeovAP2mE2ienynJUw= +github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= +github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -365,8 +363,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -377,18 +375,18 @@ github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -452,8 +450,8 @@ github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGq github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= -github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= -github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= @@ -470,8 +468,8 @@ github.com/sigstore/rekor v1.5.0 h1:rL7SghHd5HLCtsCrxw0yQg+NczGvM75EjSPPWuGjaiQ= github.com/sigstore/rekor v1.5.0/go.mod h1:D7JoVCUkxwQOpPDNYeu+CE8zeBC18Y5uDo6tF8s2rcQ= github.com/sigstore/rekor-tiles/v2 v2.0.1 h1:1Wfz15oSRNGF5Dzb0lWn5W8+lfO50ork4PGIfEKjZeo= github.com/sigstore/rekor-tiles/v2 v2.0.1/go.mod h1:Pjsbhzj5hc3MKY8FfVTYHBUHQEnP0ozC4huatu4x7OU= -github.com/sigstore/sigstore v1.10.5 h1:KqrOjDhNOVY+uOzQFat2FrGLClPPCb3uz8pK3wuI+ow= -github.com/sigstore/sigstore v1.10.5/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= +github.com/sigstore/sigstore v1.10.6 h1:YWhMQfTrJSK80QB1pbxjYeAwGKx+5UwWPPAY9hrPPZg= +github.com/sigstore/sigstore v1.10.6/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5 h1:aqHRubTITULckG9JAcq2FEhtKkT/RRE8oErfuV3smSI= @@ -508,8 +506,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.4.1 h1:K6ewW064rKZCPkRo1W/CTbTtm/+IB4+coG1iNURAGCw= -github.com/theupdateframework/go-tuf/v2 v2.4.1/go.mod h1:Nex2enPVYDFCklrnbTzl3OVwD7fgIAj0J5++z/rvCj8= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU= github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= @@ -526,8 +524,8 @@ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= -github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= -github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= +github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= @@ -547,16 +545,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -571,20 +569,20 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -600,30 +598,29 @@ golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= @@ -635,8 +632,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/barista/observability/telemetry.pb.go b/internal/barista/observability/telemetry.pb.go new file mode 100644 index 00000000000..db5a7d8f31c --- /dev/null +++ b/internal/barista/observability/telemetry.pb.go @@ -0,0 +1,289 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.4 +// protoc v5.29.3 +// source: observability/v1/telemetry.proto + +package observability + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// TelemetryEvent represents a single telemetry event from a client application. +type TelemetryEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The client application that generated the event (e.g. "github-cli", "vscode"). + App string `protobuf:"bytes,1,opt,name=app,proto3" json:"app,omitempty"` + // Required. The type of event (e.g. "usage", "lifecycle", "error"). + EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` + // Key-value string dimensions describing the event (e.g. command, os, architecture). + Dimensions map[string]string `protobuf:"bytes,3,rep,name=dimensions,proto3" json:"dimensions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Key-value numeric measures associated with the event (e.g. duration_ms, api_calls). + Measures map[string]int64 `protobuf:"bytes,4,rep,name=measures,proto3" json:"measures,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TelemetryEvent) Reset() { + *x = TelemetryEvent{} + mi := &file_observability_v1_telemetry_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TelemetryEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TelemetryEvent) ProtoMessage() {} + +func (x *TelemetryEvent) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TelemetryEvent.ProtoReflect.Descriptor instead. +func (*TelemetryEvent) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{0} +} + +func (x *TelemetryEvent) GetApp() string { + if x != nil { + return x.App + } + return "" +} + +func (x *TelemetryEvent) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *TelemetryEvent) GetDimensions() map[string]string { + if x != nil { + return x.Dimensions + } + return nil +} + +func (x *TelemetryEvent) GetMeasures() map[string]int64 { + if x != nil { + return x.Measures + } + return nil +} + +// RecordEventsRequest contains a batch of telemetry events. +type RecordEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. One or more telemetry events to record. + Events []*TelemetryEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordEventsRequest) Reset() { + *x = RecordEventsRequest{} + mi := &file_observability_v1_telemetry_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordEventsRequest) ProtoMessage() {} + +func (x *RecordEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordEventsRequest.ProtoReflect.Descriptor instead. +func (*RecordEventsRequest) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{1} +} + +func (x *RecordEventsRequest) GetEvents() []*TelemetryEvent { + if x != nil { + return x.Events + } + return nil +} + +// RecordEventsResponse is intentionally empty. +type RecordEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordEventsResponse) Reset() { + *x = RecordEventsResponse{} + mi := &file_observability_v1_telemetry_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordEventsResponse) ProtoMessage() {} + +func (x *RecordEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordEventsResponse.ProtoReflect.Descriptor instead. +func (*RecordEventsResponse) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{2} +} + +var File_observability_v1_telemetry_proto protoreflect.FileDescriptor + +var file_observability_v1_telemetry_proto_rawDesc = string([]byte{ + 0x0a, 0x20, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f, + 0x76, 0x31, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x1d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, + 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, + 0x31, 0x22, 0xf5, 0x02, 0x0a, 0x0e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x70, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x61, 0x70, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x5d, 0x0a, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, + 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, + 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x57, 0x0a, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3b, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, + 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x1a, 0x3d, 0x0a, + 0x0f, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3b, 0x0a, 0x0d, + 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x5c, 0x0a, 0x13, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x45, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2d, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, + 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, + 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, + 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0x87, 0x01, 0x0a, 0x0c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x41, 0x50, 0x49, + 0x12, 0x77, 0x0a, 0x0c, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x12, 0x32, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, + 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, + 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2f, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x74, 0x77, 0x69, 0x72, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x6f, 0x62, 0x73, 0x65, 0x72, + 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_observability_v1_telemetry_proto_rawDescOnce sync.Once + file_observability_v1_telemetry_proto_rawDescData []byte +) + +func file_observability_v1_telemetry_proto_rawDescGZIP() []byte { + file_observability_v1_telemetry_proto_rawDescOnce.Do(func() { + file_observability_v1_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc))) + }) + return file_observability_v1_telemetry_proto_rawDescData +} + +var file_observability_v1_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_observability_v1_telemetry_proto_goTypes = []any{ + (*TelemetryEvent)(nil), // 0: clientappsfe.observability.v1.TelemetryEvent + (*RecordEventsRequest)(nil), // 1: clientappsfe.observability.v1.RecordEventsRequest + (*RecordEventsResponse)(nil), // 2: clientappsfe.observability.v1.RecordEventsResponse + nil, // 3: clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry + nil, // 4: clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry +} +var file_observability_v1_telemetry_proto_depIdxs = []int32{ + 3, // 0: clientappsfe.observability.v1.TelemetryEvent.dimensions:type_name -> clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry + 4, // 1: clientappsfe.observability.v1.TelemetryEvent.measures:type_name -> clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry + 0, // 2: clientappsfe.observability.v1.RecordEventsRequest.events:type_name -> clientappsfe.observability.v1.TelemetryEvent + 1, // 3: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:input_type -> clientappsfe.observability.v1.RecordEventsRequest + 2, // 4: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:output_type -> clientappsfe.observability.v1.RecordEventsResponse + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_observability_v1_telemetry_proto_init() } +func file_observability_v1_telemetry_proto_init() { + if File_observability_v1_telemetry_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_observability_v1_telemetry_proto_goTypes, + DependencyIndexes: file_observability_v1_telemetry_proto_depIdxs, + MessageInfos: file_observability_v1_telemetry_proto_msgTypes, + }.Build() + File_observability_v1_telemetry_proto = out.File + file_observability_v1_telemetry_proto_goTypes = nil + file_observability_v1_telemetry_proto_depIdxs = nil +} diff --git a/internal/barista/observability/telemetry.twirp.go b/internal/barista/observability/telemetry.twirp.go new file mode 100644 index 00000000000..0068d6ca212 --- /dev/null +++ b/internal/barista/observability/telemetry.twirp.go @@ -0,0 +1,1117 @@ +// Code generated by protoc-gen-twirp v8.1.3, DO NOT EDIT. +// source: observability/v1/telemetry.proto + +package observability + +import context "context" +import fmt "fmt" +import http "net/http" +import io "io" +import json "encoding/json" +import strconv "strconv" +import strings "strings" + +import protojson "google.golang.org/protobuf/encoding/protojson" +import proto "google.golang.org/protobuf/proto" +import twirp "github.com/twitchtv/twirp" +import ctxsetters "github.com/twitchtv/twirp/ctxsetters" + +import bytes "bytes" +import errors "errors" +import path "path" +import url "net/url" + +// Version compatibility assertion. +// If the constant is not defined in the package, that likely means +// the package needs to be updated to work with this generated code. +// See https://twitchtv.github.io/twirp/docs/version_matrix.html +const _ = twirp.TwirpPackageMinVersion_8_1_0 + +// ====================== +// TelemetryAPI Interface +// ====================== + +// TelemetryAPI receives telemetry events from client applications. +// This endpoint is unauthenticated to support anonymous telemetry collection. +type TelemetryAPI interface { + // RecordEvents records a batch of telemetry events from a client application. + RecordEvents(context.Context, *RecordEventsRequest) (*RecordEventsResponse, error) +} + +// ============================ +// TelemetryAPI Protobuf Client +// ============================ + +type telemetryAPIProtobufClient struct { + client HTTPClient + urls [1]string + interceptor twirp.Interceptor + opts twirp.ClientOptions +} + +// NewTelemetryAPIProtobufClient creates a Protobuf client that implements the TelemetryAPI interface. +// It communicates using Protobuf and can be configured with a custom HTTPClient. +func NewTelemetryAPIProtobufClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) TelemetryAPI { + if c, ok := client.(*http.Client); ok { + client = withoutRedirects(c) + } + + clientOpts := twirp.ClientOptions{} + for _, o := range opts { + o(&clientOpts) + } + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + literalURLs := false + _ = clientOpts.ReadOpt("literalURLs", &literalURLs) + var pathPrefix string + if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + // Build method URLs: []/./ + serviceURL := sanitizeBaseURL(baseURL) + serviceURL += baseServicePath(pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") + urls := [1]string{ + serviceURL + "RecordEvents", + } + + return &telemetryAPIProtobufClient{ + client: client, + urls: urls, + interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...), + opts: clientOpts, + } +} + +func (c *telemetryAPIProtobufClient) RecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + caller := c.callRecordEvents + if c.interceptor != nil { + caller = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := c.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return c.callRecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + return caller(ctx, in) +} + +func (c *telemetryAPIProtobufClient) callRecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + out := new(RecordEventsResponse) + ctx, err := doProtobufRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out) + if err != nil { + twerr, ok := err.(twirp.Error) + if !ok { + twerr = twirp.InternalErrorWith(err) + } + callClientError(ctx, c.opts.Hooks, twerr) + return nil, err + } + + callClientResponseReceived(ctx, c.opts.Hooks) + + return out, nil +} + +// ======================== +// TelemetryAPI JSON Client +// ======================== + +type telemetryAPIJSONClient struct { + client HTTPClient + urls [1]string + interceptor twirp.Interceptor + opts twirp.ClientOptions +} + +// NewTelemetryAPIJSONClient creates a JSON client that implements the TelemetryAPI interface. +// It communicates using JSON and can be configured with a custom HTTPClient. +func NewTelemetryAPIJSONClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) TelemetryAPI { + if c, ok := client.(*http.Client); ok { + client = withoutRedirects(c) + } + + clientOpts := twirp.ClientOptions{} + for _, o := range opts { + o(&clientOpts) + } + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + literalURLs := false + _ = clientOpts.ReadOpt("literalURLs", &literalURLs) + var pathPrefix string + if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + // Build method URLs: []/./ + serviceURL := sanitizeBaseURL(baseURL) + serviceURL += baseServicePath(pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") + urls := [1]string{ + serviceURL + "RecordEvents", + } + + return &telemetryAPIJSONClient{ + client: client, + urls: urls, + interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...), + opts: clientOpts, + } +} + +func (c *telemetryAPIJSONClient) RecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + caller := c.callRecordEvents + if c.interceptor != nil { + caller = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := c.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return c.callRecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + return caller(ctx, in) +} + +func (c *telemetryAPIJSONClient) callRecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + out := new(RecordEventsResponse) + ctx, err := doJSONRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out) + if err != nil { + twerr, ok := err.(twirp.Error) + if !ok { + twerr = twirp.InternalErrorWith(err) + } + callClientError(ctx, c.opts.Hooks, twerr) + return nil, err + } + + callClientResponseReceived(ctx, c.opts.Hooks) + + return out, nil +} + +// =========================== +// TelemetryAPI Server Handler +// =========================== + +type telemetryAPIServer struct { + TelemetryAPI + interceptor twirp.Interceptor + hooks *twirp.ServerHooks + pathPrefix string // prefix for routing + jsonSkipDefaults bool // do not include unpopulated fields (default values) in the response + jsonCamelCase bool // JSON fields are serialized as lowerCamelCase rather than keeping the original proto names +} + +// NewTelemetryAPIServer builds a TwirpServer that can be used as an http.Handler to handle +// HTTP requests that are routed to the right method in the provided svc implementation. +// The opts are twirp.ServerOption modifiers, for example twirp.WithServerHooks(hooks). +func NewTelemetryAPIServer(svc TelemetryAPI, opts ...interface{}) TwirpServer { + serverOpts := newServerOpts(opts) + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + jsonSkipDefaults := false + _ = serverOpts.ReadOpt("jsonSkipDefaults", &jsonSkipDefaults) + jsonCamelCase := false + _ = serverOpts.ReadOpt("jsonCamelCase", &jsonCamelCase) + var pathPrefix string + if ok := serverOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + return &telemetryAPIServer{ + TelemetryAPI: svc, + hooks: serverOpts.Hooks, + interceptor: twirp.ChainInterceptors(serverOpts.Interceptors...), + pathPrefix: pathPrefix, + jsonSkipDefaults: jsonSkipDefaults, + jsonCamelCase: jsonCamelCase, + } +} + +// writeError writes an HTTP response with a valid Twirp error format, and triggers hooks. +// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err) +func (s *telemetryAPIServer) writeError(ctx context.Context, resp http.ResponseWriter, err error) { + writeError(ctx, resp, err, s.hooks) +} + +// handleRequestBodyError is used to handle error when the twirp server cannot read request +func (s *telemetryAPIServer) handleRequestBodyError(ctx context.Context, resp http.ResponseWriter, msg string, err error) { + if context.Canceled == ctx.Err() { + s.writeError(ctx, resp, twirp.NewError(twirp.Canceled, "failed to read request: context canceled")) + return + } + if context.DeadlineExceeded == ctx.Err() { + s.writeError(ctx, resp, twirp.NewError(twirp.DeadlineExceeded, "failed to read request: deadline exceeded")) + return + } + s.writeError(ctx, resp, twirp.WrapError(malformedRequestError(msg), err)) +} + +// TelemetryAPIPathPrefix is a convenience constant that may identify URL paths. +// Should be used with caution, it only matches routes generated by Twirp Go clients, +// with the default "/twirp" prefix and default CamelCase service and method names. +// More info: https://twitchtv.github.io/twirp/docs/routing.html +const TelemetryAPIPathPrefix = "/twirp/clientappsfe.observability.v1.TelemetryAPI/" + +func (s *telemetryAPIServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + ctx := req.Context() + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithResponseWriter(ctx, resp) + + var err error + ctx, err = callRequestReceived(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + if req.Method != "POST" { + msg := fmt.Sprintf("unsupported method %q (only POST is allowed)", req.Method) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + + // Verify path format: []/./ + prefix, pkgService, method := parseTwirpPath(req.URL.Path) + if pkgService != "clientappsfe.observability.v1.TelemetryAPI" { + msg := fmt.Sprintf("no handler for path %q", req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + if prefix != s.pathPrefix { + msg := fmt.Sprintf("invalid path prefix %q, expected %q, on path %q", prefix, s.pathPrefix, req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + + switch method { + case "RecordEvents": + s.serveRecordEvents(ctx, resp, req) + return + default: + msg := fmt.Sprintf("no handler for path %q", req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } +} + +func (s *telemetryAPIServer) serveRecordEvents(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + header := req.Header.Get("Content-Type") + i := strings.Index(header, ";") + if i == -1 { + i = len(header) + } + switch strings.TrimSpace(strings.ToLower(header[:i])) { + case "application/json": + s.serveRecordEventsJSON(ctx, resp, req) + case "application/protobuf": + s.serveRecordEventsProtobuf(ctx, resp, req) + default: + msg := fmt.Sprintf("unexpected Content-Type: %q", req.Header.Get("Content-Type")) + twerr := badRouteError(msg, req.Method, req.URL.Path) + s.writeError(ctx, resp, twerr) + } +} + +func (s *telemetryAPIServer) serveRecordEventsJSON(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + var err error + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + ctx, err = callRequestRouted(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + d := json.NewDecoder(req.Body) + rawReqBody := json.RawMessage{} + if err := d.Decode(&rawReqBody); err != nil { + s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err) + return + } + reqContent := new(RecordEventsRequest) + unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true} + if err = unmarshaler.Unmarshal(rawReqBody, reqContent); err != nil { + s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err) + return + } + + handler := s.TelemetryAPI.RecordEvents + if s.interceptor != nil { + handler = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := s.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return s.TelemetryAPI.RecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + + // Call service method + var respContent *RecordEventsResponse + func() { + defer ensurePanicResponses(ctx, resp, s.hooks) + respContent, err = handler(ctx, reqContent) + }() + + if err != nil { + s.writeError(ctx, resp, err) + return + } + if respContent == nil { + s.writeError(ctx, resp, twirp.InternalError("received a nil *RecordEventsResponse and nil error while calling RecordEvents. nil responses are not supported")) + return + } + + ctx = callResponsePrepared(ctx, s.hooks) + + marshaler := &protojson.MarshalOptions{UseProtoNames: !s.jsonCamelCase, EmitUnpopulated: !s.jsonSkipDefaults} + respBytes, err := marshaler.Marshal(respContent) + if err != nil { + s.writeError(ctx, resp, wrapInternal(err, "failed to marshal json response")) + return + } + + ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK) + resp.Header().Set("Content-Type", "application/json") + resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes))) + resp.WriteHeader(http.StatusOK) + + if n, err := resp.Write(respBytes); err != nil { + msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error()) + twerr := twirp.NewError(twirp.Unknown, msg) + ctx = callError(ctx, s.hooks, twerr) + } + callResponseSent(ctx, s.hooks) +} + +func (s *telemetryAPIServer) serveRecordEventsProtobuf(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + var err error + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + ctx, err = callRequestRouted(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + buf, err := io.ReadAll(req.Body) + if err != nil { + s.handleRequestBodyError(ctx, resp, "failed to read request body", err) + return + } + reqContent := new(RecordEventsRequest) + if err = proto.Unmarshal(buf, reqContent); err != nil { + s.writeError(ctx, resp, malformedRequestError("the protobuf request could not be decoded")) + return + } + + handler := s.TelemetryAPI.RecordEvents + if s.interceptor != nil { + handler = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := s.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return s.TelemetryAPI.RecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + + // Call service method + var respContent *RecordEventsResponse + func() { + defer ensurePanicResponses(ctx, resp, s.hooks) + respContent, err = handler(ctx, reqContent) + }() + + if err != nil { + s.writeError(ctx, resp, err) + return + } + if respContent == nil { + s.writeError(ctx, resp, twirp.InternalError("received a nil *RecordEventsResponse and nil error while calling RecordEvents. nil responses are not supported")) + return + } + + ctx = callResponsePrepared(ctx, s.hooks) + + respBytes, err := proto.Marshal(respContent) + if err != nil { + s.writeError(ctx, resp, wrapInternal(err, "failed to marshal proto response")) + return + } + + ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK) + resp.Header().Set("Content-Type", "application/protobuf") + resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes))) + resp.WriteHeader(http.StatusOK) + if n, err := resp.Write(respBytes); err != nil { + msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error()) + twerr := twirp.NewError(twirp.Unknown, msg) + ctx = callError(ctx, s.hooks, twerr) + } + callResponseSent(ctx, s.hooks) +} + +func (s *telemetryAPIServer) ServiceDescriptor() ([]byte, int) { + return twirpFileDescriptor0, 0 +} + +func (s *telemetryAPIServer) ProtocGenTwirpVersion() string { + return "v8.1.3" +} + +// PathPrefix returns the base service path, in the form: "//./" +// that is everything in a Twirp route except for the . This can be used for routing, +// for example to identify the requests that are targeted to this service in a mux. +func (s *telemetryAPIServer) PathPrefix() string { + return baseServicePath(s.pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") +} + +// ===== +// Utils +// ===== + +// HTTPClient is the interface used by generated clients to send HTTP requests. +// It is fulfilled by *(net/http).Client, which is sufficient for most users. +// Users can provide their own implementation for special retry policies. +// +// HTTPClient implementations should not follow redirects. Redirects are +// automatically disabled if *(net/http).Client is passed to client +// constructors. See the withoutRedirects function in this file for more +// details. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// TwirpServer is the interface generated server structs will support: they're +// HTTP handlers with additional methods for accessing metadata about the +// service. Those accessors are a low-level API for building reflection tools. +// Most people can think of TwirpServers as just http.Handlers. +type TwirpServer interface { + http.Handler + + // ServiceDescriptor returns gzipped bytes describing the .proto file that + // this service was generated from. Once unzipped, the bytes can be + // unmarshalled as a + // google.golang.org/protobuf/types/descriptorpb.FileDescriptorProto. + // + // The returned integer is the index of this particular service within that + // FileDescriptorProto's 'Service' slice of ServiceDescriptorProtos. This is a + // low-level field, expected to be used for reflection. + ServiceDescriptor() ([]byte, int) + + // ProtocGenTwirpVersion is the semantic version string of the version of + // twirp used to generate this file. + ProtocGenTwirpVersion() string + + // PathPrefix returns the HTTP URL path prefix for all methods handled by this + // service. This can be used with an HTTP mux to route Twirp requests. + // The path prefix is in the form: "//./" + // that is, everything in a Twirp route except for the at the end. + PathPrefix() string +} + +func newServerOpts(opts []interface{}) *twirp.ServerOptions { + serverOpts := &twirp.ServerOptions{} + for _, opt := range opts { + switch o := opt.(type) { + case twirp.ServerOption: + o(serverOpts) + case *twirp.ServerHooks: // backwards compatibility, allow to specify hooks as an argument + twirp.WithServerHooks(o)(serverOpts) + case nil: // backwards compatibility, allow nil value for the argument + continue + default: + panic(fmt.Sprintf("Invalid option type %T, please use a twirp.ServerOption", o)) + } + } + return serverOpts +} + +// WriteError writes an HTTP response with a valid Twirp error format (code, msg, meta). +// Useful outside of the Twirp server (e.g. http middleware), but does not trigger hooks. +// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err) +func WriteError(resp http.ResponseWriter, err error) { + writeError(context.Background(), resp, err, nil) +} + +// writeError writes Twirp errors in the response and triggers hooks. +func writeError(ctx context.Context, resp http.ResponseWriter, err error, hooks *twirp.ServerHooks) { + // Convert to a twirp.Error. Non-twirp errors are converted to internal errors. + var twerr twirp.Error + if !errors.As(err, &twerr) { + twerr = twirp.InternalErrorWith(err) + } + + statusCode := twirp.ServerHTTPStatusFromErrorCode(twerr.Code()) + ctx = ctxsetters.WithStatusCode(ctx, statusCode) + ctx = callError(ctx, hooks, twerr) + + respBody := marshalErrorToJSON(twerr) + + resp.Header().Set("Content-Type", "application/json") // Error responses are always JSON + resp.Header().Set("Content-Length", strconv.Itoa(len(respBody))) + resp.WriteHeader(statusCode) // set HTTP status code and send response + + _, writeErr := resp.Write(respBody) + if writeErr != nil { + // We have three options here. We could log the error, call the Error + // hook, or just silently ignore the error. + // + // Logging is unacceptable because we don't have a user-controlled + // logger; writing out to stderr without permission is too rude. + // + // Calling the Error hook would confuse users: it would mean the Error + // hook got called twice for one request, which is likely to lead to + // duplicated log messages and metrics, no matter how well we document + // the behavior. + // + // Silently ignoring the error is our least-bad option. It's highly + // likely that the connection is broken and the original 'err' says + // so anyway. + _ = writeErr + } + + callResponseSent(ctx, hooks) +} + +// sanitizeBaseURL parses the the baseURL, and adds the "http" scheme if needed. +// If the URL is unparsable, the baseURL is returned unchanged. +func sanitizeBaseURL(baseURL string) string { + u, err := url.Parse(baseURL) + if err != nil { + return baseURL // invalid URL will fail later when making requests + } + if u.Scheme == "" { + u.Scheme = "http" + } + return u.String() +} + +// baseServicePath composes the path prefix for the service (without ). +// e.g.: baseServicePath("/twirp", "my.pkg", "MyService") +// +// returns => "/twirp/my.pkg.MyService/" +// +// e.g.: baseServicePath("", "", "MyService") +// +// returns => "/MyService/" +func baseServicePath(prefix, pkg, service string) string { + fullServiceName := service + if pkg != "" { + fullServiceName = pkg + "." + service + } + return path.Join("/", prefix, fullServiceName) + "/" +} + +// parseTwirpPath extracts path components form a valid Twirp route. +// Expected format: "[]/./" +// e.g.: prefix, pkgService, method := parseTwirpPath("/twirp/pkg.Svc/MakeHat") +func parseTwirpPath(path string) (string, string, string) { + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", "" + } + method := parts[len(parts)-1] + pkgService := parts[len(parts)-2] + prefix := strings.Join(parts[0:len(parts)-2], "/") + return prefix, pkgService, method +} + +// getCustomHTTPReqHeaders retrieves a copy of any headers that are set in +// a context through the twirp.WithHTTPRequestHeaders function. +// If there are no headers set, or if they have the wrong type, nil is returned. +func getCustomHTTPReqHeaders(ctx context.Context) http.Header { + header, ok := twirp.HTTPRequestHeaders(ctx) + if !ok || header == nil { + return nil + } + copied := make(http.Header) + for k, vv := range header { + if vv == nil { + copied[k] = nil + continue + } + copied[k] = make([]string, len(vv)) + copy(copied[k], vv) + } + return copied +} + +// newRequest makes an http.Request from a client, adding common headers. +func newRequest(ctx context.Context, url string, reqBody io.Reader, contentType string) (*http.Request, error) { + req, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if customHeader := getCustomHTTPReqHeaders(ctx); customHeader != nil { + req.Header = customHeader + } + req.Header.Set("Accept", contentType) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Twirp-Version", "v8.1.3") + return req, nil +} + +// JSON serialization for errors +type twerrJSON struct { + Code string `json:"code"` + Msg string `json:"msg"` + Meta map[string]string `json:"meta,omitempty"` +} + +// marshalErrorToJSON returns JSON from a twirp.Error, that can be used as HTTP error response body. +// If serialization fails, it will use a descriptive Internal error instead. +func marshalErrorToJSON(twerr twirp.Error) []byte { + // make sure that msg is not too large + msg := twerr.Msg() + if len(msg) > 1e6 { + msg = msg[:1e6] + } + + tj := twerrJSON{ + Code: string(twerr.Code()), + Msg: msg, + Meta: twerr.MetaMap(), + } + + buf, err := json.Marshal(&tj) + if err != nil { + buf = []byte("{\"type\": \"" + twirp.Internal + "\", \"msg\": \"There was an error but it could not be serialized into JSON\"}") // fallback + } + + return buf +} + +// errorFromResponse builds a twirp.Error from a non-200 HTTP response. +// If the response has a valid serialized Twirp error, then it's returned. +// If not, the response status code is used to generate a similar twirp +// error. See twirpErrorFromIntermediary for more info on intermediary errors. +func errorFromResponse(resp *http.Response) twirp.Error { + statusCode := resp.StatusCode + statusText := http.StatusText(statusCode) + + if isHTTPRedirect(statusCode) { + // Unexpected redirect: it must be an error from an intermediary. + // Twirp clients don't follow redirects automatically, Twirp only handles + // POST requests, redirects should only happen on GET and HEAD requests. + location := resp.Header.Get("Location") + msg := fmt.Sprintf("unexpected HTTP status code %d %q received, Location=%q", statusCode, statusText, location) + return twirpErrorFromIntermediary(statusCode, msg, location) + } + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return wrapInternal(err, "failed to read server error response body") + } + + var tj twerrJSON + dec := json.NewDecoder(bytes.NewReader(respBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(&tj); err != nil || tj.Code == "" { + // Invalid JSON response; it must be an error from an intermediary. + msg := fmt.Sprintf("Error from intermediary with HTTP status code %d %q", statusCode, statusText) + return twirpErrorFromIntermediary(statusCode, msg, string(respBodyBytes)) + } + + errorCode := twirp.ErrorCode(tj.Code) + if !twirp.IsValidErrorCode(errorCode) { + msg := "invalid type returned from server error response: " + tj.Code + return twirp.InternalError(msg).WithMeta("body", string(respBodyBytes)) + } + + twerr := twirp.NewError(errorCode, tj.Msg) + for k, v := range tj.Meta { + twerr = twerr.WithMeta(k, v) + } + return twerr +} + +// twirpErrorFromIntermediary maps HTTP errors from non-twirp sources to twirp errors. +// The mapping is similar to gRPC: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md. +// Returned twirp Errors have some additional metadata for inspection. +func twirpErrorFromIntermediary(status int, msg string, bodyOrLocation string) twirp.Error { + var code twirp.ErrorCode + if isHTTPRedirect(status) { // 3xx + code = twirp.Internal + } else { + switch status { + case 400: // Bad Request + code = twirp.Internal + case 401: // Unauthorized + code = twirp.Unauthenticated + case 403: // Forbidden + code = twirp.PermissionDenied + case 404: // Not Found + code = twirp.BadRoute + case 429: // Too Many Requests + code = twirp.ResourceExhausted + case 502, 503, 504: // Bad Gateway, Service Unavailable, Gateway Timeout + code = twirp.Unavailable + default: // All other codes + code = twirp.Unknown + } + } + + twerr := twirp.NewError(code, msg) + twerr = twerr.WithMeta("http_error_from_intermediary", "true") // to easily know if this error was from intermediary + twerr = twerr.WithMeta("status_code", strconv.Itoa(status)) + if isHTTPRedirect(status) { + twerr = twerr.WithMeta("location", bodyOrLocation) + } else { + twerr = twerr.WithMeta("body", bodyOrLocation) + } + return twerr +} + +func isHTTPRedirect(status int) bool { + return status >= 300 && status <= 399 +} + +// wrapInternal wraps an error with a prefix as an Internal error. +// The original error cause is accessible by github.com/pkg/errors.Cause. +func wrapInternal(err error, prefix string) twirp.Error { + return twirp.InternalErrorWith(&wrappedError{prefix: prefix, cause: err}) +} + +type wrappedError struct { + prefix string + cause error +} + +func (e *wrappedError) Error() string { return e.prefix + ": " + e.cause.Error() } +func (e *wrappedError) Unwrap() error { return e.cause } // for go1.13 + errors.Is/As +func (e *wrappedError) Cause() error { return e.cause } // for github.com/pkg/errors + +// ensurePanicResponses makes sure that rpc methods causing a panic still result in a Twirp Internal +// error response (status 500), and error hooks are properly called with the panic wrapped as an error. +// The panic is re-raised so it can be handled normally with middleware. +func ensurePanicResponses(ctx context.Context, resp http.ResponseWriter, hooks *twirp.ServerHooks) { + if r := recover(); r != nil { + // Wrap the panic as an error so it can be passed to error hooks. + // The original error is accessible from error hooks, but not visible in the response. + err := errFromPanic(r) + twerr := &internalWithCause{msg: "Internal service panic", cause: err} + // Actually write the error + writeError(ctx, resp, twerr, hooks) + // If possible, flush the error to the wire. + f, ok := resp.(http.Flusher) + if ok { + f.Flush() + } + + panic(r) + } +} + +// errFromPanic returns the typed error if the recovered panic is an error, otherwise formats as error. +func errFromPanic(p interface{}) error { + if err, ok := p.(error); ok { + return err + } + return fmt.Errorf("panic: %v", p) +} + +// internalWithCause is a Twirp Internal error wrapping an original error cause, +// but the original error message is not exposed on Msg(). The original error +// can be checked with go1.13+ errors.Is/As, and also by (github.com/pkg/errors).Unwrap +type internalWithCause struct { + msg string + cause error +} + +func (e *internalWithCause) Unwrap() error { return e.cause } // for go1.13 + errors.Is/As +func (e *internalWithCause) Cause() error { return e.cause } // for github.com/pkg/errors +func (e *internalWithCause) Error() string { return e.msg + ": " + e.cause.Error() } +func (e *internalWithCause) Code() twirp.ErrorCode { return twirp.Internal } +func (e *internalWithCause) Msg() string { return e.msg } +func (e *internalWithCause) Meta(key string) string { return "" } +func (e *internalWithCause) MetaMap() map[string]string { return nil } +func (e *internalWithCause) WithMeta(key string, val string) twirp.Error { return e } + +// malformedRequestError is used when the twirp server cannot unmarshal a request +func malformedRequestError(msg string) twirp.Error { + return twirp.NewError(twirp.Malformed, msg) +} + +// badRouteError is used when the twirp server cannot route a request +func badRouteError(msg string, method, url string) twirp.Error { + err := twirp.NewError(twirp.BadRoute, msg) + err = err.WithMeta("twirp_invalid_route", method+" "+url) + return err +} + +// withoutRedirects makes sure that the POST request can not be redirected. +// The standard library will, by default, redirect requests (including POSTs) if it gets a 302 or +// 303 response, and also 301s in go1.8. It redirects by making a second request, changing the +// method to GET and removing the body. This produces very confusing error messages, so instead we +// set a redirect policy that always errors. This stops Go from executing the redirect. +// +// We have to be a little careful in case the user-provided http.Client has its own CheckRedirect +// policy - if so, we'll run through that policy first. +// +// Because this requires modifying the http.Client, we make a new copy of the client and return it. +func withoutRedirects(in *http.Client) *http.Client { + copy := *in + copy.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if in.CheckRedirect != nil { + // Run the input's redirect if it exists, in case it has side effects, but ignore any error it + // returns, since we want to use ErrUseLastResponse. + err := in.CheckRedirect(req, via) + _ = err // Silly, but this makes sure generated code passes errcheck -blank, which some people use. + } + return http.ErrUseLastResponse + } + return © +} + +// doProtobufRequest makes a Protobuf request to the remote Twirp service. +func doProtobufRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) { + reqBodyBytes, err := proto.Marshal(in) + if err != nil { + return ctx, wrapInternal(err, "failed to marshal proto request") + } + reqBody := bytes.NewBuffer(reqBodyBytes) + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + req, err := newRequest(ctx, url, reqBody, "application/protobuf") + if err != nil { + return ctx, wrapInternal(err, "could not build request") + } + ctx, err = callClientRequestPrepared(ctx, hooks, req) + if err != nil { + return ctx, err + } + + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return ctx, wrapInternal(err, "failed to do request") + } + defer func() { _ = resp.Body.Close() }() + + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if resp.StatusCode != 200 { + return ctx, errorFromResponse(resp) + } + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return ctx, wrapInternal(err, "failed to read response body") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if err = proto.Unmarshal(respBodyBytes, out); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal proto response") + } + return ctx, nil +} + +// doJSONRequest makes a JSON request to the remote Twirp service. +func doJSONRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) { + marshaler := &protojson.MarshalOptions{UseProtoNames: true} + reqBytes, err := marshaler.Marshal(in) + if err != nil { + return ctx, wrapInternal(err, "failed to marshal json request") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + req, err := newRequest(ctx, url, bytes.NewReader(reqBytes), "application/json") + if err != nil { + return ctx, wrapInternal(err, "could not build request") + } + ctx, err = callClientRequestPrepared(ctx, hooks, req) + if err != nil { + return ctx, err + } + + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return ctx, wrapInternal(err, "failed to do request") + } + + defer func() { + cerr := resp.Body.Close() + if err == nil && cerr != nil { + err = wrapInternal(cerr, "failed to close response body") + } + }() + + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if resp.StatusCode != 200 { + return ctx, errorFromResponse(resp) + } + + d := json.NewDecoder(resp.Body) + rawRespBody := json.RawMessage{} + if err := d.Decode(&rawRespBody); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal json response") + } + unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true} + if err = unmarshaler.Unmarshal(rawRespBody, out); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal json response") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + return ctx, nil +} + +// Call twirp.ServerHooks.RequestReceived if the hook is available +func callRequestReceived(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) { + if h == nil || h.RequestReceived == nil { + return ctx, nil + } + return h.RequestReceived(ctx) +} + +// Call twirp.ServerHooks.RequestRouted if the hook is available +func callRequestRouted(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) { + if h == nil || h.RequestRouted == nil { + return ctx, nil + } + return h.RequestRouted(ctx) +} + +// Call twirp.ServerHooks.ResponsePrepared if the hook is available +func callResponsePrepared(ctx context.Context, h *twirp.ServerHooks) context.Context { + if h == nil || h.ResponsePrepared == nil { + return ctx + } + return h.ResponsePrepared(ctx) +} + +// Call twirp.ServerHooks.ResponseSent if the hook is available +func callResponseSent(ctx context.Context, h *twirp.ServerHooks) { + if h == nil || h.ResponseSent == nil { + return + } + h.ResponseSent(ctx) +} + +// Call twirp.ServerHooks.Error if the hook is available +func callError(ctx context.Context, h *twirp.ServerHooks, err twirp.Error) context.Context { + if h == nil || h.Error == nil { + return ctx + } + return h.Error(ctx, err) +} + +func callClientResponseReceived(ctx context.Context, h *twirp.ClientHooks) { + if h == nil || h.ResponseReceived == nil { + return + } + h.ResponseReceived(ctx) +} + +func callClientRequestPrepared(ctx context.Context, h *twirp.ClientHooks, req *http.Request) (context.Context, error) { + if h == nil || h.RequestPrepared == nil { + return ctx, nil + } + return h.RequestPrepared(ctx, req) +} + +func callClientError(ctx context.Context, h *twirp.ClientHooks, err twirp.Error) { + if h == nil || h.Error == nil { + return + } + h.Error(ctx, err) +} + +var twirpFileDescriptor0 = []byte{ + // 353 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x92, 0x4d, 0x4b, 0x02, 0x41, + 0x18, 0xc7, 0x59, 0xb7, 0x24, 0x9f, 0xec, 0x85, 0x49, 0x62, 0x11, 0x04, 0xf1, 0xe4, 0xa5, 0x1d, + 0xd4, 0x4b, 0x24, 0x1e, 0x8a, 0x3c, 0x44, 0x08, 0xb1, 0x08, 0x41, 0x14, 0xb1, 0xab, 0x4f, 0x36, + 0xb8, 0x2f, 0xd3, 0xce, 0xec, 0xca, 0x7c, 0x82, 0x3e, 0x71, 0xf7, 0x70, 0x56, 0x65, 0x57, 0x22, + 0xf1, 0x36, 0x3b, 0x33, 0xbf, 0xdf, 0xff, 0xf9, 0x2f, 0x03, 0xcd, 0xc8, 0x13, 0x18, 0xa7, 0xae, + 0xc7, 0x7c, 0x26, 0x15, 0x4d, 0x3b, 0x54, 0xa2, 0x8f, 0x01, 0xca, 0x58, 0xd9, 0x3c, 0x8e, 0x64, + 0x44, 0x1a, 0x13, 0x9f, 0x61, 0x28, 0x5d, 0xce, 0xc5, 0x07, 0xda, 0x85, 0xeb, 0x76, 0xda, 0x69, + 0xfd, 0x94, 0xe0, 0x74, 0xbc, 0x46, 0x86, 0x29, 0x86, 0x92, 0x9c, 0x83, 0xe9, 0x72, 0x6e, 0x19, + 0x4d, 0xa3, 0x5d, 0x71, 0x96, 0x4b, 0xd2, 0x00, 0xc0, 0xe5, 0xd1, 0xbb, 0x54, 0x1c, 0xad, 0x92, + 0x3e, 0xa8, 0xe8, 0x9d, 0xb1, 0xe2, 0x48, 0xde, 0x00, 0xa6, 0x2c, 0xc0, 0x50, 0xb0, 0x28, 0x14, + 0x96, 0xd9, 0x34, 0xdb, 0xc7, 0xdd, 0x81, 0xfd, 0x6f, 0xae, 0x5d, 0xcc, 0xb4, 0xef, 0x37, 0xfc, + 0x30, 0x94, 0xb1, 0x72, 0x72, 0x42, 0xf2, 0x0c, 0x47, 0x01, 0xba, 0x22, 0x89, 0x51, 0x58, 0x07, + 0x5a, 0xde, 0xdf, 0x4f, 0x3e, 0x5a, 0xd1, 0x99, 0x7a, 0x23, 0xab, 0x0f, 0xe0, 0x6c, 0x2b, 0x77, + 0xd9, 0x7d, 0x8e, 0x6a, 0xdd, 0x7d, 0x8e, 0x8a, 0xd4, 0xe0, 0x30, 0x75, 0xfd, 0x64, 0x5d, 0x3b, + 0xfb, 0xb8, 0x29, 0x5d, 0x1b, 0xf5, 0x3e, 0x9c, 0x14, 0xcc, 0xbb, 0x60, 0x33, 0x07, 0xb7, 0x5e, + 0xe1, 0xc2, 0xc1, 0x49, 0x14, 0x4f, 0xf5, 0x88, 0xc2, 0xc1, 0xaf, 0x04, 0x85, 0x24, 0x43, 0x28, + 0xeb, 0xff, 0x2a, 0x2c, 0x43, 0x37, 0xbd, 0xda, 0xab, 0xa9, 0xb3, 0x82, 0x5b, 0x97, 0x50, 0x2b, + 0xda, 0x05, 0x8f, 0x42, 0x81, 0xdd, 0x6f, 0x03, 0xaa, 0x1b, 0xe4, 0xf6, 0xe9, 0x81, 0x2c, 0xa0, + 0x9a, 0xbf, 0x48, 0xba, 0x3b, 0xf2, 0xfe, 0x98, 0xb9, 0xde, 0xdb, 0x8b, 0xc9, 0x26, 0xb9, 0x1b, + 0xbd, 0x3c, 0xce, 0x98, 0xfc, 0x4c, 0x3c, 0x7b, 0x12, 0x05, 0x34, 0x5b, 0xd2, 0xbc, 0x87, 0xf2, + 0xf9, 0x8c, 0xba, 0x9c, 0x51, 0xb9, 0x60, 0x31, 0xa7, 0xdb, 0xef, 0xbc, 0x5f, 0xd8, 0xf0, 0xca, + 0xfa, 0xb1, 0xf7, 0x7e, 0x03, 0x00, 0x00, 0xff, 0xff, 0xff, 0x5b, 0x87, 0x22, 0x10, 0x03, 0x00, + 0x00, +} diff --git a/internal/ci/ci.go b/internal/ci/ci.go new file mode 100644 index 00000000000..6438127b093 --- /dev/null +++ b/internal/ci/ci.go @@ -0,0 +1,19 @@ +// Package ci provides helpers for detecting CI/CD execution environments. +package ci + +import "os" + +// IsCI determines if the current execution context is within a known CI/CD system. +// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. +func IsCI() bool { + return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity + os.Getenv("RUN_ID") != "" // TaskCluster, dsari +} + +// IsGitHubActions determines if the current execution context is within GitHub Actions. +// GitHub Actions sets the GITHUB_ACTIONS environment variable to "true" for all steps. +// See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables. +func IsGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") == "true" +} diff --git a/internal/ci/ci_test.go b/internal/ci/ci_test.go new file mode 100644 index 00000000000..6b2a28b54af --- /dev/null +++ b/internal/ci/ci_test.go @@ -0,0 +1,56 @@ +package ci + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCI(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {name: "no CI env vars", env: map[string]string{}, want: false}, + {name: "CI set", env: map[string]string{"CI": "true"}, want: true}, + {name: "BUILD_NUMBER set", env: map[string]string{"BUILD_NUMBER": "42"}, want: true}, + {name: "RUN_ID set", env: map[string]string{"RUN_ID": "abc"}, want: true}, + {name: "CI empty string", env: map[string]string{"CI": ""}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + for k, v := range tt.env { + t.Setenv(k, v) + } + assert.Equal(t, tt.want, IsCI()) + }) + } +} + +func TestIsGitHubActions(t *testing.T) { + tests := []struct { + name string + value string + set bool + want bool + }{ + {name: "unset", set: false, want: false}, + {name: "true", value: "true", set: true, want: true}, + {name: "false", value: "false", set: true, want: false}, + {name: "empty", value: "", set: true, want: false}, + {name: "other value", value: "yes", set: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + if tt.set { + t.Setenv("GITHUB_ACTIONS", tt.value) + } + assert.Equal(t, tt.want, IsGitHubActions()) + }) + } +} diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 0d1eaf5b34f..2bd0a5e3be6 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -60,10 +60,11 @@ const ( // API is the interface to the codespace service. type API struct { - client func() (*http.Client, error) - githubAPI string - githubServer string - retryBackoff time.Duration + client func() (*http.Client, error) + externalClient func() (*http.Client, error) + githubAPI string + githubServer string + retryBackoff time.Duration } // New creates a new API client connecting to the configured endpoints with the HTTP client. @@ -93,10 +94,11 @@ func New(f *cmdutil.Factory) *API { } return &API{ - client: f.HttpClient, - githubAPI: strings.TrimSuffix(apiURL, "/"), - githubServer: strings.TrimSuffix(serverURL, "/"), - retryBackoff: 100 * time.Millisecond, + client: f.HttpClient, + externalClient: f.ExternalHttpClient, + githubAPI: strings.TrimSuffix(apiURL, "/"), + githubServer: strings.TrimSuffix(serverURL, "/"), + retryBackoff: 100 * time.Millisecond, } } @@ -1214,12 +1216,8 @@ func (a *API) withRetry(f func() (*http.Response, error)) (*http.Response, error }, backoff.WithMaxRetries(bo, 3)) } -// HTTPClient returns the HTTP client used to make requests to the API. -func (a *API) HTTPClient() (*http.Client, error) { - httpClient, err := a.client() - if err != nil { - return nil, err - } - - return httpClient, nil +// ExternalHTTPClient returns an HTTP client for requests to non-GitHub hosts. +// It must not carry GitHub authentication credentials. +func (a *API) ExternalHTTPClient() (*http.Client, error) { + return a.externalClient() } diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index 92185120a2e..c30e126abc6 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -39,7 +39,7 @@ func connectionReady(codespace *api.Codespace) bool { type apiClient interface { GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) StartCodespace(ctx context.Context, name string) error - HTTPClient() (*http.Client, error) + ExternalHTTPClient() (*http.Client, error) } type progressIndicator interface { @@ -66,12 +66,12 @@ func GetCodespaceConnection(ctx context.Context, progress progressIndicator, api progress.StartProgressIndicatorWithLabel("Connecting to codespace") defer progress.StopProgressIndicator() - httpClient, err := apiClient.HTTPClient() + externalHttpClient, err := apiClient.ExternalHTTPClient() if err != nil { return nil, fmt.Errorf("error getting http client: %w", err) } - return connection.NewCodespaceConnection(ctx, codespace, httpClient) + return connection.NewCodespaceConnection(ctx, codespace, externalHttpClient) } // waitUntilCodespaceConnectionReady waits for a Codespace to be running and is able to be connected to. diff --git a/internal/codespaces/codespaces_test.go b/internal/codespaces/codespaces_test.go index d931b96ef4b..aceb970483f 100644 --- a/internal/codespaces/codespaces_test.go +++ b/internal/codespaces/codespaces_test.go @@ -202,8 +202,8 @@ func (m *mockApiClient) GetCodespace(ctx context.Context, name string, includeCo return m.onGetCodespace() } -func (m *mockApiClient) HTTPClient() (*http.Client, error) { - panic("Not implemented") +func (m *mockApiClient) ExternalHTTPClient() (*http.Client, error) { + return nil, nil } type mockProgressIndicator struct{} diff --git a/internal/config/config.go b/internal/config/config.go index 4d83bd4e5a1..dadfa284b30 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ const ( promptKey = "prompt" preferEditorPromptKey = "prefer_editor_prompt" spinnerKey = "spinner" + telemetryKey = "telemetry" userKey = "user" usersKey = "users" versionKey = "version" @@ -169,6 +170,11 @@ func (c *cfg) Spinner(hostname string) gh.ConfigEntry { return c.GetOrDefault(hostname, spinnerKey).Unwrap() } +func (c *cfg) Telemetry() gh.ConfigEntry { + // Intentionally panic if there is no user provided value or default value (which would be a programmer error) + return c.GetOrDefault("", telemetryKey).Unwrap() +} + func (c *cfg) Version() o.Option[string] { return c.get("", versionKey) } @@ -574,7 +580,7 @@ color_labels: disabled accessible_colors: disabled # Whether an accessible prompter should be used. Supported values: enabled, disabled accessible_prompter: disabled -# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled +# Whether to use an animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled spinner: enabled ` @@ -675,13 +681,22 @@ var Options = []ConfigOption{ }, { Key: spinnerKey, - Description: "whether to use a animated spinner as a progress indicator", + Description: "whether to use an animated spinner as a progress indicator", DefaultValue: "enabled", AllowedValues: []string{"enabled", "disabled"}, CurrentValue: func(c gh.Config, hostname string) string { return c.Spinner(hostname).Value }, }, + { + Key: telemetryKey, + Description: "whether telemetry is enabled, disabled, or logging", + DefaultValue: "enabled", + AllowedValues: []string{"enabled", "disabled", "log"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.Telemetry().Value + }, + }, } func HomeDirPath(subdir string) (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 67a9a98d1ab..57cca23740f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -182,3 +182,34 @@ func TestSetUserSpecificKeyNoUserPresent(t *testing.T) { requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val) requireNoKey(t, c.cfg, []string{hostsKey, host, usersKey}) } + +func TestTelemetry(t *testing.T) { + t.Run("returns default when not configured", func(t *testing.T) { + c := newTestConfig() + + entry := c.Telemetry() + + require.Equal(t, "enabled", entry.Value) + require.Equal(t, gh.ConfigDefaultProvided, entry.Source) + }) + + t.Run("returns user configured value", func(t *testing.T) { + c := newTestConfig() + c.Set("", telemetryKey, "disabled") + + entry := c.Telemetry() + + require.Equal(t, "disabled", entry.Value) + require.Equal(t, gh.ConfigUserProvided, entry.Source) + }) + + t.Run("returns log when configured", func(t *testing.T) { + c := newTestConfig() + c.Set("", telemetryKey, "log") + + entry := c.Telemetry() + + require.Equal(t, "log", entry.Value) + require.Equal(t, gh.ConfigUserProvided, entry.Source) + }) +} diff --git a/internal/config/stub.go b/internal/config/stub.go index ea60254db85..fe5e277b62b 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -61,6 +61,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { mock.BrowserFunc = func(hostname string) gh.ConfigEntry { return cfg.Browser(hostname) } + mock.TelemetryFunc = func() gh.ConfigEntry { + return cfg.Telemetry() + } mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry { return cfg.ColorLabels(hostname) } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index d4ef62070a8..98be5a46439 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -48,10 +48,18 @@ type IssueFeatures struct { // - The replaceActorsForAssignable mutation // - The requestReviewsByLogin mutation ApiActorsSupported bool + + // TODO IssueRelationshipsCleanup - remove when GHES 3.18 support ends (~October 2026) + // IssueRelationshipsSupported indicates the host supports issue + // relationships (blocked-by/blocking). Available on github.com and + // GHES 3.19+. Issue types and sub-issues are GA on all supported GHES + // versions (3.17+) and do not need feature detection. + IssueRelationshipsSupported bool } var allIssueFeatures = IssueFeatures{ - ApiActorsSupported: true, + ApiActorsSupported: true, + IssueRelationshipsSupported: true, } type PullRequestFeatures struct { @@ -159,9 +167,35 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { return allIssueFeatures, nil } - return IssueFeatures{ - ApiActorsSupported: false, // TODO ApiActorsSupported — actor-based mutations unavailable on GHES - }, nil + features := IssueFeatures{ + ApiActorsSupported: false, // TODO ApiActorsSupported - actor-based mutations unavailable on GHES + } + + // Detect issue relationship support (GHES 3.19+) via schema introspection. + // Issue types and sub-issues are GA on all supported GHES versions (3.17+) + // and do not need detection. + var featureDetection struct { + Issue struct { + Fields []struct { + Name string + } `graphql:"fields(includeDeprecated: true)"` + } `graphql:"Issue: __type(name: \"Issue\")"` + } + + gql := api.NewClientFromHTTP(d.httpClient) + err := gql.Query(d.host, "Issue_fields", &featureDetection, nil) + if err != nil { + return IssueFeatures{}, err + } + + for _, field := range featureDetection.Issue.Fields { + if field.Name == "blockedBy" { + features.IssueRelationshipsSupported = true + break + } + } + + return features, nil } func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) { diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index f24e31f4c73..6b6ed675180 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -12,6 +12,9 @@ import ( ) func TestIssueFeatures(t *testing.T) { + issueFieldsWithRelationships := `{"data":{"Issue":{"fields":[{"name":"title"},{"name":"body"},{"name":"blockedBy"}]}}}` + issueFieldsWithoutRelationships := `{"data":{"Issue":{"fields":[{"name":"title"},{"name":"body"}]}}}` + tests := []struct { name string hostname string @@ -23,7 +26,8 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - ApiActorsSupported: true, + ApiActorsSupported: true, + IssueRelationshipsSupported: true, }, wantErr: false, }, @@ -31,15 +35,32 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - ApiActorsSupported: true, + ApiActorsSupported: true, + IssueRelationshipsSupported: true, }, wantErr: false, }, { - name: "GHE", + name: "GHE with relationship support", hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields`: issueFieldsWithRelationships, + }, + wantFeatures: IssueFeatures{ + ApiActorsSupported: false, + IssueRelationshipsSupported: true, + }, + wantErr: false, + }, + { + name: "GHE without relationship support", + hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields`: issueFieldsWithoutRelationships, + }, wantFeatures: IssueFeatures{ - ApiActorsSupported: false, + ApiActorsSupported: false, + IssueRelationshipsSupported: false, }, wantErr: false, }, diff --git a/internal/flock/flock.go b/internal/flock/flock.go new file mode 100644 index 00000000000..6d5af9f011b --- /dev/null +++ b/internal/flock/flock.go @@ -0,0 +1,8 @@ +package flock + +import "errors" + +// ErrLocked is returned when the file is already locked by another process. +// Callers can check for this to distinguish contention from permanent errors. +// This is intended to be an OS-agnostic sentinel error. +var ErrLocked = errors.New("file is locked by another process") diff --git a/internal/flock/flock_test.go b/internal/flock/flock_test.go new file mode 100644 index 00000000000..69b3a73b50e --- /dev/null +++ b/internal/flock/flock_test.go @@ -0,0 +1,99 @@ +package flock_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTryLock(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string // returns lock path + wantErr error + verify func(t *testing.T, f *os.File) + }{ + { + name: "acquires lock and returns writable file handle", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "test.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := f.WriteString("hello") + require.NoError(t, err) + _, err = f.Seek(0, 0) + require.NoError(t, err) + buf := make([]byte, 5) + n, err := f.Read(buf) + assert.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + }, + }, + { + name: "creates lock file if it does not exist", + setup: func(t *testing.T) string { + dir := filepath.Join(t.TempDir(), "subdir") + require.NoError(t, os.MkdirAll(dir, 0o755)) + return filepath.Join(dir, "new.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := os.Stat(f.Name()) + assert.NoError(t, err) + }, + }, + { + name: "second lock on same path returns ErrLocked", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "contended.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + t.Cleanup(unlock) + return lockPath + }, + wantErr: flock.ErrLocked, + }, + { + name: "lock succeeds after unlock", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "reuse.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + unlock() + return lockPath + }, + }, + { + name: "fails on non-existent directory", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "no", "such", "dir", "test.lock") + }, + wantErr: os.ErrNotExist, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := tt.setup(t) + + f, unlock, err := flock.TryLock(lockPath) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, f) + defer unlock() + + if tt.verify != nil { + tt.verify(t, f) + } + }) + } +} diff --git a/internal/flock/flock_unix.go b/internal/flock/flock_unix.go new file mode 100644 index 00000000000..73f8b15570c --- /dev/null +++ b/internal/flock/flock_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +package flock + +import ( + "errors" + "os" + "syscall" +) + +// TryLock attempts to acquire an exclusive, non-blocking flock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid platform differences with +// mandatory locking on Windows. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/internal/flock/flock_windows.go b/internal/flock/flock_windows.go new file mode 100644 index 00000000000..4795af08336 --- /dev/null +++ b/internal/flock/flock_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package flock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// TryLock attempts to acquire an exclusive, non-blocking lock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid Windows mandatory lock conflicts. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + ol := new(windows.Overlapped) + handle := windows.Handle(f.Fd()) + err = windows.LockFileEx( + handle, + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, 0, + ol, + ) + if err != nil { + _ = f.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = windows.UnlockFileEx(handle, 0, 1, 0, ol) + _ = f.Close() + }, nil +} diff --git a/internal/gh/gh.go b/internal/gh/gh.go index aa90a5268b6..759a931f2b7 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -57,6 +57,8 @@ type Config interface { PreferEditorPrompt(hostname string) ConfigEntry // Spinner returns the configured spinner setting, optionally scoped by host. Spinner(hostname string) ConfigEntry + // Telemetry returns the configured telemetry setting, ignoring host scoping since telemetry is a global setting. + Telemetry() ConfigEntry // Aliases provides persistent storage and modification of command aliases. Aliases() AliasConfig diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go new file mode 100644 index 00000000000..197b955b4c1 --- /dev/null +++ b/internal/gh/ghtelemetry/telemetry.go @@ -0,0 +1,32 @@ +package ghtelemetry + +type Dimensions map[string]string + +type Measures map[string]int64 + +type Event struct { + Type string + Dimensions Dimensions + Measures Measures +} + +type Disabler interface { + Disable() +} + +type EventRecorder interface { + Record(event Event) + Disabler +} + +type CommandRecorder interface { + EventRecorder + SetSampleRate(rate int) +} + +type Service interface { + CommandRecorder + Flush() +} + +const SAMPLE_ALL = 100 diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go index 9f3f807993b..31e35cb1899 100644 --- a/internal/gh/mock/config.go +++ b/internal/gh/mock/config.go @@ -4,9 +4,10 @@ package ghmock import ( + "sync" + "github.com/cli/cli/v2/internal/gh" o "github.com/cli/cli/v2/pkg/option" - "sync" ) // Ensure, that ConfigMock does implement gh.Config. @@ -70,6 +71,9 @@ var _ gh.Config = &ConfigMock{} // SpinnerFunc: func(hostname string) gh.ConfigEntry { // panic("mock out the Spinner method") // }, +// TelemetryFunc: func() gh.ConfigEntry { +// panic("mock out the Telemetry method") +// }, // VersionFunc: func() o.Option[string] { // panic("mock out the Version method") // }, @@ -134,6 +138,9 @@ type ConfigMock struct { // SpinnerFunc mocks the Spinner method. SpinnerFunc func(hostname string) gh.ConfigEntry + // TelemetryFunc mocks the Telemetry method. + TelemetryFunc func() gh.ConfigEntry + // VersionFunc mocks the Version method. VersionFunc func() o.Option[string] @@ -227,6 +234,9 @@ type ConfigMock struct { // Hostname is the hostname argument value. Hostname string } + // Telemetry holds details about calls to the Telemetry method. + Telemetry []struct { + } // Version holds details about calls to the Version method. Version []struct { } @@ -251,6 +261,7 @@ type ConfigMock struct { lockPrompt sync.RWMutex lockSet sync.RWMutex lockSpinner sync.RWMutex + lockTelemetry sync.RWMutex lockVersion sync.RWMutex lockWrite sync.RWMutex } @@ -796,6 +807,33 @@ func (mock *ConfigMock) SpinnerCalls() []struct { return calls } +// Telemetry calls TelemetryFunc. +func (mock *ConfigMock) Telemetry() gh.ConfigEntry { + if mock.TelemetryFunc == nil { + panic("ConfigMock.TelemetryFunc: method is nil but Config.Telemetry was just called") + } + callInfo := struct { + }{} + mock.lockTelemetry.Lock() + mock.calls.Telemetry = append(mock.calls.Telemetry, callInfo) + mock.lockTelemetry.Unlock() + return mock.TelemetryFunc() +} + +// TelemetryCalls gets all the calls that were made to Telemetry. +// Check the length with: +// +// len(mockedConfig.TelemetryCalls()) +func (mock *ConfigMock) TelemetryCalls() []struct { +} { + var calls []struct { + } + mock.lockTelemetry.RLock() + calls = mock.calls.Telemetry + mock.lockTelemetry.RUnlock() + return calls +} + // Version calls VersionFunc. func (mock *ConfigMock) Version() o.Option[string] { if mock.VersionFunc == nil { diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 8690078c66e..b7e15bd5f4e 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -9,6 +9,8 @@ import ( "os" "os/exec" "path/filepath" + "slices" + "strconv" "strings" "time" @@ -17,14 +19,21 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" + "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + xcolor "github.com/cli/go-gh/v2/pkg/x/color" "github.com/cli/safeexec" "github.com/mgutz/ansi" "github.com/spf13/cobra" @@ -45,12 +54,84 @@ func Main() exitCode { buildVersion := build.Version hasDebug, _ := utils.IsDebugEnabled() - cmdFactory := factory.New(buildVersion, string(agents.Detect())) - stderr := cmdFactory.IOStreams.ErrOut + cfg, cfgErr := config.NewConfig() + if cfgErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to load config: %s\n", cfgErr) + } + cfgFunc := func() (gh.Config, error) { return cfg, cfgErr } - ctx := context.Background() + var ioStreams *iostreams.IOStreams + if cfgErr == nil { + ioStreams = newIOStreams(cfg) + } else { + ioStreams = iostreams.System() + } + stderr := ioStreams.ErrOut + + ghExecutablePath := executablePath("gh") + + additionalCommonDimensions := ghtelemetry.Dimensions{ + "version": strings.TrimPrefix(buildVersion, "v"), + "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), + "agent": string(agents.Detect()), + "ci": strconv.FormatBool(ci.IsCI()), + "github_actions": strconv.FormatBool(ci.IsGitHubActions()), + "accessible_colors": strconv.FormatBool(ioStreams.AccessibleColorsEnabled()), + "accessible_prompter": strconv.FormatBool(ioStreams.AccessiblePrompterEnabled()), + "color_labels": strconv.FormatBool(ioStreams.ColorLabels()), + "spinner_disabled": strconv.FormatBool(ioStreams.GetSpinnerDisabled()), + } + + var telemetryService ghtelemetry.Service + switch { + case cfgErr != nil: + // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. + telemetryService = &telemetry.NoOpService{} + default: + telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) + telemetryDisabled := mightBeGHESUser(cfg) + + switch telemetryState { + case telemetry.Disabled: + telemetryService = &telemetry.NoOpService{} + case telemetry.Logged: + // Always construct the real service in log mode so that the log + // flusher runs and surfaces an explicit "Telemetry payload: none" + // marker when no events will be sent. This gives the user an + // observable signal that telemetry is wired up even when their + // context (e.g. GHES) causes events to be dropped. + telemetryService = telemetry.NewService( + telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), + telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), + ) + if telemetryDisabled { + telemetryService.Disable() + } + case telemetry.Enabled: + if telemetryDisabled { + telemetryService = &telemetry.NoOpService{} + break + } + sampleRate := 1 + if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 { + sampleRate = v + } + additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) + telemetryService = telemetry.NewService( + telemetry.GitHubFlusher(ghExecutablePath), + telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), + telemetry.WithSampleRate(sampleRate), + ) + default: + fmt.Fprintf(stderr, "invalid telemetry configuration: %q\n", cfg.Telemetry().Value) + return exitError + } + } + defer telemetryService.Flush() + + cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfgFunc, ioStreams, ghExecutablePath, telemetryService) - if cfg, err := cmdFactory.Config(); err == nil { + if cfgErr == nil { var m migration.MultiAccount if err := cfg.Migrate(m); err != nil { fmt.Fprintln(stderr, err) @@ -58,6 +139,7 @@ func Main() exitCode { } } + ctx := context.Background() updateCtx, updateCancel := context.WithCancel(ctx) defer updateCancel() updateMessageChan := make(chan *update.ReleaseInfo) @@ -90,7 +172,7 @@ func Main() exitCode { cobra.MousetrapHelpText = "" } - rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate) + rootCmd, err := root.NewCmdRoot(cmdFactory, telemetryService, buildVersion, buildDate) if err != nil { fmt.Fprintf(stderr, "failed to create root command: %s\n", err) return exitError @@ -150,7 +232,11 @@ func Main() exitCode { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { - fmt.Fprintln(stderr, "Try authenticating with: gh auth login") + authCommand := "gh auth login" + if cfg, cfgErr := cmdFactory.Config(); cfgErr == nil { + authCommand = authRecoveryCommand(cfg, httpErr) + } + fmt.Fprintf(stderr, "Try authenticating with: %s\n", authCommand) } else if u := factory.SSOURL(); u != "" { // handles organization SAML enforcement error fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) @@ -167,7 +253,7 @@ func Main() exitCode { updateCancel() // if the update checker hasn't completed by now, abort it newRelease := <-updateMessageChan if newRelease != nil { - isHomebrew := isUnderHomebrew(cmdFactory.Executable()) + isHomebrew := isUnderHomebrew(cmdFactory.ExecutablePath) if isHomebrew && isRecentRelease(newRelease.PublishedAt) { // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core return exitOK @@ -214,6 +300,20 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { } } +func authRecoveryCommand(cfg gh.Config, httpErr api.HTTPError) string { + if httpErr.RequestURL == nil { + return "gh auth login" + } + + hostname := ghauth.NormalizeHostname(httpErr.RequestURL.Hostname()) + token, source := cfg.Authentication().ActiveToken(hostname) + if shared.AuthTokenRefreshable(token, source) { + return fmt.Sprintf("gh auth refresh -h %s", hostname) + } + + return fmt.Sprintf("gh auth login -h %s", hostname) +} + func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { if updaterEnabled == "" || !update.ShouldCheckForUpdate() { return nil, nil @@ -245,3 +345,148 @@ func isUnderHomebrew(ghBinary string) bool { brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(ghBinary, brewBinPrefix) } + +func newIOStreams(cfg gh.Config) *iostreams.IOStreams { + io := iostreams.System() + + if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled { + io.SetNeverPrompt(true) + } else if prompt := cfg.Prompt(""); prompt.Value == "disabled" { + io.SetNeverPrompt(true) + } + + falseyValues := []string{"false", "0", "no", ""} + + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") + if accessiblePrompterIsSet { + if !slices.Contains(falseyValues, accessiblePrompterValue) { + io.SetAccessiblePrompterEnabled(true) + } + } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { + io.SetAccessiblePrompterEnabled(true) + } + + experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") + if experimentalPrompterIsSet { + if !slices.Contains(falseyValues, experimentalPrompterValue) { + io.SetExperimentalPrompterEnabled(true) + } + } + + ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") + if ghSpinnerDisabledIsSet { + if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { + io.SetSpinnerDisabled(true) + } + } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { + io.SetSpinnerDisabled(true) + } + + // Pager precedence + // 1. GH_PAGER + // 2. pager from config + // 3. PAGER + if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists { + io.SetPager(ghPager) + } else if pager := cfg.Pager(""); pager.Value != "" { + io.SetPager(pager.Value) + } + + if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { + switch ghColorLabels { + case "", "0", "false", "no": + io.SetColorLabels(false) + default: + io.SetColorLabels(true) + } + } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { + io.SetColorLabels(true) + } + + io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) + + return io +} + +// Executable is the path to the currently invoked binary +func executablePath(executableName string) string { + ghPath := os.Getenv("GH_PATH") + if ghPath != "" { + return ghPath + } + + if strings.ContainsRune(executableName, os.PathSeparator) { + return executableName + } + + return executable(executableName) +} + +// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. +// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in +// PATH, return the absolute location to the program. +// +// The idea is that the result of this function is callable in the future and refers to the same +// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software +// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. +// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of +// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew +// location. +// +// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute +// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git +// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh +// auth login`, running `brew update` will print out authentication errors as git is unable to locate +// Homebrew-installed `gh` +func executable(fallback string) string { + exe, err := os.Executable() + if err != nil { + return fallback + } + + base := filepath.Base(exe) + path := os.Getenv("PATH") + for _, dir := range filepath.SplitList(path) { + p, err := filepath.Abs(filepath.Join(dir, base)) + if err != nil { + continue + } + f, err := os.Lstat(p) + if err != nil { + continue + } + + if p == exe { + return p + } else if f.Mode()&os.ModeSymlink != 0 { + realP, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + realExe, err := filepath.EvalSymlinks(exe) + if err != nil { + continue + } + if realP == realExe { + return p + } + } + } + + return exe +} + +func mightBeGHESUser(cfg gh.Config) bool { + if os.Getenv("GH_ENTERPRISE_TOKEN") != "" || os.Getenv("GITHUB_ENTERPRISE_TOKEN") != "" { + return true + } + + if host := os.Getenv("GH_HOST"); host != "" && ghauth.IsEnterprise(host) { + return true + } + + // If any targeted host is Enterprise, then the user is likely a GHES user. + return slices.ContainsFunc(cfg.Authentication().Hosts(), func(host string) bool { + return ghauth.IsEnterprise(host) + }) +} diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index 08bbceb8532..d389bd7448f 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -5,10 +5,17 @@ import ( "errors" "fmt" "net" + "net/url" "testing" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/pkg/cmdutil" + ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" ) func Test_printError(t *testing.T) { @@ -76,3 +83,506 @@ check your internet connection or https://githubstatus.com }) } } + +func Test_newIOStreams_pager(t *testing.T) { + tests := []struct { + name string + env map[string]string + config gh.Config + wantPager string + }{ + { + name: "GH_PAGER and PAGER set", + env: map[string]string{ + "GH_PAGER": "GH_PAGER", + "PAGER": "PAGER", + }, + wantPager: "GH_PAGER", + }, + { + name: "GH_PAGER and config pager set", + env: map[string]string{ + "GH_PAGER": "GH_PAGER", + }, + config: pagerConfig(), + wantPager: "GH_PAGER", + }, + { + name: "config pager and PAGER set", + env: map[string]string{ + "PAGER": "PAGER", + }, + config: pagerConfig(), + wantPager: "CONFIG_PAGER", + }, + { + name: "only PAGER set", + env: map[string]string{ + "PAGER": "PAGER", + }, + wantPager: "PAGER", + }, + { + name: "GH_PAGER set to blank string", + env: map[string]string{ + "GH_PAGER": "", + "PAGER": "PAGER", + }, + wantPager: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.wantPager, io.GetPager()) + }) + } +} + +func Test_newIOStreams_prompt(t *testing.T) { + tests := []struct { + name string + config gh.Config + promptDisabled bool + env map[string]string + }{ + { + name: "default config", + promptDisabled: false, + }, + { + name: "config with prompt disabled", + config: disablePromptConfig(), + promptDisabled: true, + }, + { + name: "prompt disabled via GH_PROMPT_DISABLED env var", + env: map[string]string{"GH_PROMPT_DISABLED": "1"}, + promptDisabled: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt()) + }) + } +} + +func Test_newIOStreams_spinnerDisabled(t *testing.T) { + tests := []struct { + name string + config gh.Config + spinnerDisabled bool + env map[string]string + }{ + { + name: "default config", + spinnerDisabled: false, + }, + { + name: "config with spinner disabled", + config: disableSpinnersConfig(), + spinnerDisabled: true, + }, + { + name: "config with spinner enabled", + config: enableSpinnersConfig(), + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", + env: map[string]string{"GH_SPINNER_DISABLED": "0"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = false", + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = no", + env: map[string]string{"GH_SPINNER_DISABLED": "no"}, + spinnerDisabled: false, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", + env: map[string]string{"GH_SPINNER_DISABLED": "1"}, + spinnerDisabled: true, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = true", + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + { + name: "config enabled but env disabled, respects env", + config: enableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + { + name: "config disabled but env enabled, respects env", + config: disableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) + }) + } +} + +func Test_newIOStreams_accessiblePrompterEnabled(t *testing.T) { + tests := []struct { + name string + config gh.Config + accessiblePrompterEnabled bool + env map[string]string + }{ + { + name: "default config", + accessiblePrompterEnabled: false, + }, + { + name: "config with accessible prompter enabled", + config: enableAccessiblePrompterConfig(), + accessiblePrompterEnabled: true, + }, + { + name: "config with accessible prompter disabled", + config: disableAccessiblePrompterConfig(), + accessiblePrompterEnabled: false, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"}, + accessiblePrompterEnabled: false, + }, + { + name: "config disabled but env enabled, respects env", + config: disableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "config enabled but env disabled, respects env", + config: enableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"}, + accessiblePrompterEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled()) + }) + } +} + +func Test_newIOStreams_colorLabels(t *testing.T) { + tests := []struct { + name string + config gh.Config + colorLabelsEnabled bool + env map[string]string + }{ + { + name: "default config", + colorLabelsEnabled: false, + }, + { + name: "config with colorLabels enabled", + config: enableColorLabelsConfig(), + colorLabelsEnabled: true, + }, + { + name: "config with colorLabels disabled", + config: disableColorLabelsConfig(), + colorLabelsEnabled: false, + }, + { + name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "1"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "true"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "yes"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": ""}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "0"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "false"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "no"}, + colorLabelsEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels()) + }) + } +} + +func Test_mightBeGHESUser(t *testing.T) { + tests := []struct { + name string + env map[string]string + config gh.Config + want bool + }{ + { + name: "GH_ENTERPRISE_TOKEN set", + env: map[string]string{"GH_ENTERPRISE_TOKEN": "some-token"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "GITHUB_ENTERPRISE_TOKEN set", + env: map[string]string{"GITHUB_ENTERPRISE_TOKEN": "some-token"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "no env vars, config has enterprise host", + config: config.NewFromString("hosts:\n ghes.example.com:\n oauth_token: abc123\n"), + want: true, + }, + { + name: "no env vars, config has only github.com", + config: config.NewFromString("hosts:\n github.com:\n oauth_token: abc123\n"), + want: false, + }, + { + name: "no env vars, config has no hosts", + config: config.NewBlankConfig(), + want: false, + }, + { + name: "no env vars, config has github.com and enterprise host", + config: config.NewFromString("hosts:\n github.com:\n oauth_token: abc123\n ghes.example.com:\n oauth_token: def456\n"), + want: true, + }, + { + name: "no env vars, config has tenancy host", + config: config.NewFromString("hosts:\n my-company.ghe.com:\n oauth_token: abc123\n"), + want: false, + }, + { + name: "GH_HOST set to enterprise host", + env: map[string]string{"GH_HOST": "ghes.example.com"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "GH_HOST set to github.com", + env: map[string]string{"GH_HOST": "github.com"}, + config: config.NewBlankConfig(), + want: false, + }, + { + name: "GH_HOST set to tenancy host", + env: map[string]string{"GH_HOST": "my-company.ghe.com"}, + config: config.NewBlankConfig(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + got := mightBeGHESUser(tt.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func pagerConfig() gh.Config { + return config.NewFromString("pager: CONFIG_PAGER") +} + +func disablePromptConfig() gh.Config { + return config.NewFromString("prompt: disabled") +} + +func enableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: enabled") +} + +func disableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: disabled") +} + +func disableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: disabled") +} + +func enableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: enabled") +} + +func disableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: disabled") +} + +func enableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: enabled") +} + +func Test_authRecoveryCommand(t *testing.T) { + tests := []struct { + name string + token string + source string + requestURL string + want string + }{ + { + name: "stored oauth token", + token: "gho_abc123", + source: "oauth_token", + requestURL: "https://api.github.com/graphql", + want: "gh auth refresh -h github.com", + }, + { + name: "stored pat", + token: "github_pat_abc123", + source: "oauth_token", + requestURL: "https://api.github.com/graphql", + want: "gh auth login -h github.com", + }, + { + name: "env token", + token: "gho_abc123", + source: "GH_TOKEN", + requestURL: "https://api.github.com/graphql", + want: "gh auth login -h github.com", + }, + { + name: "missing request url", + token: "gho_abc123", + source: "oauth_token", + want: "gh auth login", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authCfg := config.NewBlankConfig().Authentication() + authCfg.SetActiveToken(tt.token, tt.source) + cfg := &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + return authCfg + }, + } + + var requestURL *url.URL + if tt.requestURL != "" { + var err error + requestURL, err = url.Parse(tt.requestURL) + if err != nil { + t.Fatalf("failed to parse request URL: %v", err) + } + } + + httpErr := api.HTTPError{ + HTTPError: &ghAPI.HTTPError{ + RequestURL: requestURL, + StatusCode: 401, + }, + } + + got := authRecoveryCommand(cfg, httpErr) + if got != tt.want { + t.Errorf("authRecoveryCommand() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/cmdutil/factory_test.go b/internal/ghcmd/executable_test.go similarity index 94% rename from pkg/cmdutil/factory_test.go rename to internal/ghcmd/executable_test.go index 0103a04f1b5..f0374429bcd 100644 --- a/pkg/cmdutil/factory_test.go +++ b/internal/ghcmd/executable_test.go @@ -1,10 +1,12 @@ -package cmdutil +package ghcmd import ( "os" "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/require" ) func Test_executable(t *testing.T) { @@ -113,11 +115,8 @@ func Test_executable_relative(t *testing.T) { } } -func Test_Executable_override(t *testing.T) { +func TestExecutablePath(t *testing.T) { override := strings.Join([]string{"C:", "cygwin64", "home", "gh.exe"}, string(os.PathSeparator)) t.Setenv("GH_PATH", override) - f := Factory{} - if got := f.Executable(); got != override { - t.Errorf("executable() = %q, want %q", got, override) - } + require.Equal(t, override, executablePath("gh")) } diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index 7abfd83acaf..dfea6dd946b 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -96,3 +96,19 @@ func HostPrefix(hostname string) string { } return fmt.Sprintf("https://%s/", hostname) } + +func CategorizeHost(host string) string { + if host == defaultHostname { + return "github.com" + } + + if ghauth.IsEnterprise(host) { + return "ghes" + } + + if ghauth.IsTenancy(host) { + return "tenancy" + } + + return "uncategorized" +} diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index 1b7e0146d04..c4f447780a9 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -157,3 +157,57 @@ func TestRESTPrefix(t *testing.T) { }) } } + +func TestCategorizeHost(t *testing.T) { + tests := []struct { + name string + host string + want string + }{ + { + name: "github.com returns github.com", + host: "github.com", + want: "github.com", + }, + { + name: "classic GHES hostname returns ghes", + host: "ghe.io", + want: "ghes", + }, + { + name: "arbitrary enterprise hostname returns ghes", + host: "enterprise.example.com", + want: "ghes", + }, + { + name: "tenant subdomain of ghe.com returns tenancy", + host: "tenant.ghe.com", + want: "tenancy", + }, + { + name: "api subdomain under tenant returns tenancy", + host: "api.tenant.ghe.com", + want: "tenancy", + }, + { + name: "bare ghe.com returns ghes", + host: "ghe.com", + want: "ghes", + }, + { + name: "github.localhost returns uncategorized", + host: "github.localhost", + want: "uncategorized", + }, + { + name: "github.com subdomain returns uncategorized", + host: "garage.github.com", + want: "uncategorized", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, CategorizeHost(tt.host)) + }) + } +} diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 372b0e6705f..ee6eba3a93e 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -1,10 +1,11 @@ -//go:build !windows +//go:build linux || darwin package prompter_test import ( "fmt" "io" + "os" "slices" "strings" "testing" @@ -17,6 +18,7 @@ import ( "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" ) // The following tests are broadly testing the accessible prompter, and NOT asserting @@ -34,8 +36,6 @@ import ( // but doesn't mandate that prompts always look exactly the same. func TestAccessiblePrompter(t *testing.T) { - beforePasswordSendTimeout := 100 * time.Microsecond - t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -505,8 +505,8 @@ func TestAccessiblePrompter(t *testing.T) { _, err := console.ExpectString("Enter password") require.NoError(t, err) - // Wait to ensure huh has time to set the echo mode - time.Sleep(beforePasswordSendTimeout) + // Wait until huh has disabled echo mode on the TTY + require.NoError(t, waitForEchoDisabled(console.Tty(), 5*time.Second)) // Enter a number _, err = console.SendLine(dummyPassword) @@ -596,8 +596,8 @@ func TestAccessiblePrompter(t *testing.T) { _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) - // Wait to ensure huh has time to set the echo mode - time.Sleep(beforePasswordSendTimeout) + // Wait until huh has disabled echo mode on the TTY + require.NoError(t, waitForEchoDisabled(console.Tty(), 5*time.Second)) // Enter some dummy auth token _, err = console.SendLine(dummyAuthToken) @@ -641,8 +641,8 @@ func TestAccessiblePrompter(t *testing.T) { _, err = console.ExpectString("Paste your authentication token:") require.NoError(t, err) - // Wait to ensure huh has time to set the echo mode - time.Sleep(beforePasswordSendTimeout) + // Wait until huh has disabled echo mode on the TTY + require.NoError(t, waitForEchoDisabled(console.Tty(), 5*time.Second)) // Now enter some dummy auth token to return control back to the test _, err = console.SendLine(dummyAuthTokenForAfterFailure) @@ -956,3 +956,21 @@ func testCloser(t *testing.T, closer io.Closer) { t.Errorf("Close failed: %s", err) } } + +// waitForEchoDisabled polls the TTY until echo mode is disabled or the +// timeout is reached. This is used in password and auth token tests to +// ensure that huh has configured the terminal before we send input. +func waitForEchoDisabled(tty *os.File, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + termios, err := unix.IoctlGetTermios(int(tty.Fd()), ioctlGetTermios) + if err != nil { + return fmt.Errorf("getting terminal attributes: %w", err) + } + if termios.Lflag&unix.ECHO == 0 { + return nil + } + time.Sleep(time.Millisecond) + } + return fmt.Errorf("timed out waiting for echo mode to be disabled") +} diff --git a/internal/prompter/echo_darwin_test.go b/internal/prompter/echo_darwin_test.go new file mode 100644 index 00000000000..2cb3130d9db --- /dev/null +++ b/internal/prompter/echo_darwin_test.go @@ -0,0 +1,7 @@ +//go:build darwin + +package prompter_test + +import "golang.org/x/sys/unix" + +const ioctlGetTermios = unix.TIOCGETA diff --git a/internal/prompter/echo_linux_test.go b/internal/prompter/echo_linux_test.go new file mode 100644 index 00000000000..ad63bd1d526 --- /dev/null +++ b/internal/prompter/echo_linux_test.go @@ -0,0 +1,7 @@ +//go:build linux + +package prompter_test + +import "golang.org/x/sys/unix" + +const ioctlGetTermios = unix.TCGETS diff --git a/internal/skills/discovery/collisions.go b/internal/skills/discovery/collisions.go new file mode 100644 index 00000000000..6aae3c7b7de --- /dev/null +++ b/internal/skills/discovery/collisions.go @@ -0,0 +1,55 @@ +package discovery + +import ( + "fmt" + "sort" + "strings" +) + +// NameCollision represents a group of skills that share the same install +// directory name and would overwrite each other when installed. +type NameCollision struct { + Name string // the conflicting skill name (directory name) + DisplayNames []string // display names of each conflicting skill +} + +// FindNameCollisions detects skills whose Name fields collide (meaning they +// would be installed to the same directory) and returns a sorted slice of +// collisions. Skills are installed flat by Name, so two skills with the same +// Name but different Namespace values still conflict. Callers decide how to +// present the conflict to the user. +func FindNameCollisions(skills []Skill) []NameCollision { + byName := make(map[string][]Skill) + for _, s := range skills { + byName[s.Name] = append(byName[s.Name], s) + } + + var collisions []NameCollision + for name, group := range byName { + if len(group) <= 1 { + continue + } + names := make([]string, len(group)) + for i, s := range group { + names[i] = s.DisplayName() + } + collisions = append(collisions, NameCollision{Name: name, DisplayNames: names}) + } + + sort.Slice(collisions, func(i, j int) bool { + return collisions[i].Name < collisions[j].Name + }) + return collisions +} + +// FormatCollisions builds a human-readable string listing each collision, +// suitable for embedding in an error message. Each collision is formatted as +// "name: display1, display2" and collisions are separated by newlines with +// leading indentation. +func FormatCollisions(collisions []NameCollision) string { + lines := make([]string, len(collisions)) + for i, c := range collisions { + lines[i] = fmt.Sprintf("%s: %s", c.Name, strings.Join(c.DisplayNames, ", ")) + } + return strings.Join(lines, "\n ") +} diff --git a/internal/skills/discovery/collisions_test.go b/internal/skills/discovery/collisions_test.go new file mode 100644 index 00000000000..fff5199ba7b --- /dev/null +++ b/internal/skills/discovery/collisions_test.go @@ -0,0 +1,80 @@ +package discovery + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindNameCollisions(t *testing.T) { + tests := []struct { + name string + skills []Skill + want []NameCollision + }{ + { + name: "no collisions", + skills: []Skill{ + {Name: "code-review", Path: "skills/code-review"}, + {Name: "issue-triage", Path: "skills/issue-triage"}, + }, + want: nil, + }, + { + name: "single collision with different conventions", + skills: []Skill{ + {Name: "pr-summary", Path: "skills/pr-summary"}, + {Name: "pr-summary", Path: "plugins/hubot/skills/pr-summary", Convention: "plugins"}, + }, + want: []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"pr-summary", "[plugins] pr-summary"}}, + }, + }, + { + name: "collisions sorted by name", + skills: []Skill{ + {Name: "octocat-lint", Path: "skills/octocat-lint"}, + {Name: "octocat-lint", Path: "skills/hubot/octocat-lint"}, + {Name: "code-review", Path: "skills/code-review"}, + {Name: "code-review", Path: "skills/monalisa/code-review"}, + }, + want: []NameCollision{ + {Name: "code-review", DisplayNames: []string{"code-review", "code-review"}}, + {Name: "octocat-lint", DisplayNames: []string{"octocat-lint", "octocat-lint"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindNameCollisions(tt.skills) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatCollisions(t *testing.T) { + tests := []struct { + name string + collisions []NameCollision + want string + }{ + { + name: "formats multiple collisions", + collisions: []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, + {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + }, + want: "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", + }, + { + name: "nil input returns empty string", + collisions: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, FormatCollisions(tt.collisions)) + }) + } +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go new file mode 100644 index 00000000000..ff6f1286e6b --- /dev/null +++ b/internal/skills/discovery/discovery.go @@ -0,0 +1,1078 @@ +package discovery + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "sync/atomic" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/skills/frontmatter" +) + +// specNamePattern matches the strict agentskills.io name spec: +// 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens. +var specNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// TreeTooLargeError is returned when a repository's git tree exceeds the +// GitHub API truncation limit and full skill discovery is not possible. +type TreeTooLargeError struct { + Owner string + Repo string +} + +func (e *TreeTooLargeError) Error() string { + return fmt.Sprintf("repository tree for %s/%s is too large for full discovery", e.Owner, e.Repo) +} + +// safeNamePattern matches names that are safe for filesystem use during discovery. +// Allows letters (any case), numbers, hyphens, underscores, dots, and spaces. +// Must start with a letter or number. This matches copilot-agent-runtime's SKILL_NAME_REGEX. +var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\- ]*$`) + +// Skill represents a discovered skill in a repository. +type Skill struct { + Name string + Namespace string // author/scope prefix for namespaced skills + Description string + Path string // path within the repo, e.g. "skills/git-commit" + BlobSHA string // SHA of the SKILL.md blob + TreeSHA string // SHA of the skill directory tree + Convention string // which directory convention matched +} + +// DisplayName returns the skill name, prefixed with namespace if present +// to disambiguate skills from different authors in the same repository. +// Skills discovered via non-standard conventions (plugins, root) include +// a convention tag to distinguish them from identically-named skills in +// the standard skills/ directory. +func (s Skill) DisplayName() string { + name := s.Name + if s.Namespace != "" { + name = s.Namespace + "/" + name + } + switch s.Convention { + case "plugins": + return "[plugins] " + name + case "root": + return "[root] " + name + case "hidden-dir", "hidden-dir-namespaced": + return "[hidden-dir] " + name + default: + return name + } +} + +// InstallName returns the relative path used for the install directory. +// For namespaced skills it returns "namespace/name" (creating a nested directory), +// otherwise it returns the plain name. Callers should use filepath.FromSlash +// when building OS-specific paths from this value. +func (s Skill) InstallName() string { + if s.Namespace != "" { + return s.Namespace + "/" + s.Name + } + return s.Name +} + +// IsHiddenDirConvention returns true if the skill was discovered in a hidden +// (dot-prefixed) directory such as .claude/skills/ or .agents/skills/. +func (s Skill) IsHiddenDirConvention() bool { + return s.Convention == "hidden-dir" || s.Convention == "hidden-dir-namespaced" +} + +// HasHiddenDirSkills returns true if any of the given skills were discovered +// in hidden directories. +func HasHiddenDirSkills(skills []Skill) bool { + for _, s := range skills { + if s.IsHiddenDirConvention() { + return true + } + } + return false +} + +// HiddenDirFilterResult holds the outcome of partitioning skills into standard +// and hidden-dir buckets. +type HiddenDirFilterResult struct { + Standard []Skill + HiddenCount int +} + +// PartitionHiddenDirSkills splits skills into standard and hidden-dir groups. +func PartitionHiddenDirSkills(skills []Skill) HiddenDirFilterResult { + var r HiddenDirFilterResult + for _, s := range skills { + if s.IsHiddenDirConvention() { + r.HiddenCount++ + } else { + r.Standard = append(r.Standard, s) + } + } + return r +} + +// ResolvedRef contains the resolved git reference and its SHA. +type ResolvedRef struct { + Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA + SHA string // commit SHA +} + +// IsFullyQualifiedRef returns true if ref uses the "refs/heads/" or "refs/tags/" prefix. +func IsFullyQualifiedRef(ref string) bool { + return strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/") +} + +// ShortRef strips the "refs/heads/" or "refs/tags/" prefix from a fully qualified ref, +// returning the short name. If the ref is not fully qualified it is returned as-is. +func ShortRef(ref string) string { + if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok { + return after + } + if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok { + return after + } + return ref +} + +type treeEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Type string `json:"type"` + SHA string `json:"sha"` + Size int `json:"size"` +} + +// SkillFile represents a file within a skill directory. +type SkillFile struct { + Path string // relative path within the skill directory + SHA string // blob SHA for fetching content + Size int // file size in bytes +} + +type treeResponse struct { + SHA string `json:"sha"` + Tree []treeEntry `json:"tree"` + Truncated bool `json:"truncated"` +} + +type RepoVisibility string + +const ( + RepoVisibilityPublic RepoVisibility = "public" + RepoVisibilityPrivate RepoVisibility = "private" + RepoVisibilityInternal RepoVisibility = "internal" +) + +func parseRepoVisibility(s string) (RepoVisibility, error) { + switch s { + case "public": + return RepoVisibilityPublic, nil + case "private": + return RepoVisibilityPrivate, nil + case "internal": + return RepoVisibilityInternal, nil + default: + return "", fmt.Errorf("unknown repository visibility: %q", s) + } +} + +// FetchRepoVisibility returns the repository visibility: "public", "private", or "internal". +func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisibility, error) { + apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + var resp struct { + Visibility string `json:"visibility"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return "", err + } + return parseRepoVisibility(resp.Visibility) +} + +// ResolveRef determines the git ref to use for a given owner/repo. +// Priority: explicit version > latest release tag > default branch. +func ResolveRef(client *api.Client, host, owner, repo, version string) (*ResolvedRef, error) { + if version != "" { + return resolveExplicitRef(client, host, owner, repo, version) + } + ref, err := resolveLatestRelease(client, host, owner, repo) + if err == nil { + return ref, nil + } + // Only fall back to the default branch when the repository genuinely + // has no releases (404) or the latest release has no tag. Any other + // API error (403, 500, network failure, …) is surfaced immediately + // so it cannot silently mask problems and cause an unexpected ref to + // be used. + var nre *noReleasesError + if !errors.As(err, &nre) { + return nil, err + } + return resolveDefaultBranch(client, host, owner, repo) +} + +// resolveExplicitRef resolves a user-supplied version string. It supports: +// - fully qualified refs: "refs/tags/v1.0" or "refs/heads/main" +// - short names: tried as branch first, then tag, then commit SHA +// - bare SHAs: resolved as commit SHA +// +// When a short name matches both a branch and a tag, the branch wins. +// The returned Ref is always a fully qualified ref (refs/heads/* or refs/tags/*) +// unless the input resolves to a bare commit SHA. +func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*ResolvedRef, error) { + // Handle fully-qualified refs: resolve directly without ambiguity. + if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok { + return resolveTagRef(client, host, owner, repo, after) + } + if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok { + return resolveBranchRef(client, host, owner, repo, after) + } + + // Short name: try branch first, then tag, then commit SHA. + // Only fall through on 404 (not found); surface other errors + // (403, 500, network) immediately to avoid masking real failures. + if resolved, err := resolveBranchRef(client, host, owner, repo, ref); err == nil { + return resolved, nil + } else if !isNotFound(err) { + return nil, err + } + if resolved, err := resolveTagRef(client, host, owner, repo, ref); err == nil { + return resolved, nil + } else if !isNotFound(err) { + return nil, err + } + + commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(ref)) + var commitResp struct { + SHA string `json:"sha"` + } + if err := client.REST(host, "GET", commitPath, nil, &commitResp); err == nil { + return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil + } else if !isNotFound(err) { + return nil, err + } + + return nil, fmt.Errorf("ref %q not found as branch, tag, or commit in %s/%s", ref, owner, repo) +} + +// resolveTagRef looks up a tag by short name and returns a fully qualified ref. +// For annotated tags, the tag object is dereferenced to obtain the commit SHA. +func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*ResolvedRef, error) { + tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(tag)) + var refResp struct { + Object struct { + SHA string `json:"sha"` + Type string `json:"type"` + } `json:"object"` + } + if err := client.REST(host, "GET", tagPath, nil, &refResp); err != nil { + return nil, fmt.Errorf("tag %q not found in %s/%s: %w", tag, owner, repo, err) + } + sha := refResp.Object.SHA + if refResp.Object.Type == "tag" { + derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) + var tagResp struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil { + return nil, fmt.Errorf("could not dereference annotated tag %q: %w", tag, err) + } + sha = tagResp.Object.SHA + } + return &ResolvedRef{Ref: "refs/tags/" + tag, SHA: sha}, nil +} + +// resolveBranchRef looks up a branch by short name and returns a fully qualified ref. +func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*ResolvedRef, error) { + refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(branch)) + var refResp struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil { + return nil, fmt.Errorf("branch %q not found in %s/%s: %w", branch, owner, repo, err) + } + return &ResolvedRef{Ref: "refs/heads/" + branch, SHA: refResp.Object.SHA}, nil +} + +// isNotFound returns true if the error is an HTTP 404 response. +func isNotFound(err error) bool { + var httpErr api.HTTPError + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} + +// noReleasesError signals that the repository has no usable releases, +// which is the only case where ResolveRef should fall back to the +// default branch. +type noReleasesError struct { + reason string +} + +func (e *noReleasesError) Error() string { return e.reason } + +func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { + apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo)) + var resp struct { + TagName string `json:"tag_name"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + // A 404 means the repository has no releases. This is the + // only case where falling back to the default branch is safe. + // Any other HTTP error (403, 500, …) or network failure is + // returned as-is so ResolveRef surfaces it rather than + // silently falling back. + if isNotFound(err) { + return nil, &noReleasesError{reason: fmt.Sprintf("no releases found for %s/%s", owner, repo)} + } + return nil, fmt.Errorf("could not fetch latest release: %w", err) + } + if resp.TagName == "" { + return nil, &noReleasesError{reason: "latest release has no tag"} + } + return resolveTagRef(client, host, owner, repo, resp.TagName) +} + +func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { + apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + var resp struct { + DefaultBranch string `json:"default_branch"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return nil, fmt.Errorf("could not determine default branch: %w", err) + } + branch := resp.DefaultBranch + if branch == "" { + return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo) + } + return resolveBranchRef(client, host, owner, repo, branch) +} + +// skillMatch represents a matched SKILL.md file and its convention. +type skillMatch struct { + entry treeEntry + name string + namespace string + skillDir string + convention string +} + +// MatchesSkillPath checks if a file path matches any known skill convention +// and returns the skill name. Returns empty string if the path doesn't match. +func MatchesSkillPath(filePath string) string { + m := matchSkillConventions(treeEntry{Path: filePath}) + if m == nil { + return "" + } + return m.name +} + +// MatchSkillPath checks if a file path matches any known skill convention +// and returns the skill name and namespace. Returns empty strings if the +// path doesn't match. The namespace is non-empty for namespaced skills +// (e.g. skills/author/name/SKILL.md) and plugin skills. +func MatchSkillPath(filePath string) (name, namespace string) { + m := matchSkillConventions(treeEntry{Path: filePath}) + if m == nil { + return "", "" + } + return m.name, m.namespace +} + +// IsSkillPath reports whether a skill selector looks like a repo-relative path +// rather than a simple skill name. +func IsSkillPath(name string) bool { + name = strings.TrimSuffix(name, "/") + if name == "" { + return false + } + if strings.HasSuffix(name, "/SKILL.md") { + return true + } + if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { + return true + } + if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") { + return true + } + if strings.Count(name, "/") >= 2 { + return true + } + return false +} + +// matchSkillConventions checks if a blob path matches any known skill convention. +func matchSkillConventions(entry treeEntry) *skillMatch { + if path.Base(entry.Path) != "SKILL.md" { + return nil + } + + dir := path.Dir(entry.Path) + parentDir := path.Dir(dir) + skillName := path.Base(dir) + + if !validateName(skillName) { + return nil + } + + if parentDir == "skills" { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"} + } + + grandparentDir := path.Dir(parentDir) + if grandparentDir == "skills" { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"} + } + + if path.Base(parentDir) == "skills" && path.Dir(grandparentDir) == "plugins" { + namespace := path.Base(grandparentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"} + } + + // Deeply nested skills/ directory: /skills//SKILL.md + // Matches skills/ at any depth, not just at the repository root. + // Exclude paths with dot-prefixed segments (handled by + // matchHiddenDirConventions) and paths under a plugins/ directory + // (handled by the plugins convention above). + if path.Base(parentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"} + } + + // Deeply nested namespaced: /skills///SKILL.md + if path.Base(grandparentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"} + } + + if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "root"} + } + + return nil +} + +// matchHiddenDirConventions checks if a blob path matches a skill convention +// under a path that contains a hidden (dot-prefixed) directory. These patterns +// mirror the standard skills/ conventions, but only when a hidden segment +// appears anywhere in the ancestor path: +// +// - {prefix}/.{host}/{suffix}/skills/*/SKILL.md -> "hidden-dir" +// - {prefix}/.{host}/{suffix}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" +func matchHiddenDirConventions(entry treeEntry) *skillMatch { + if path.Base(entry.Path) != "SKILL.md" { + return nil + } + if !hasHiddenSegment(entry.Path) { + return nil + } + + // {prefix}/.{host}/{suffix}/skills/* + // {prefix}/.{host}/{suffix}/skills/{scope}/* + dir := path.Dir(entry.Path) + skillName := path.Base(dir) + + if !validateName(skillName) { + return nil + } + + // {prefix}/.{host}/{suffix}/skills + // {prefix}/.{host}/{suffix}/skills/{scope} + parentDir := path.Dir(dir) + + // {prefix}/.{host}/{suffix}/skills/*/SKILL.md + if path.Base(parentDir) == "skills" { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"} + } + + // {prefix}/.{host}/{suffix}/skills/{scope}/*/SKILL.md + grandparentDir := path.Dir(parentDir) + if path.Base(grandparentDir) == "skills" { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"} + } + + return nil +} + +// DiscoverOptions controls optional discovery behaviors. +type DiscoverOptions struct { +} + +// DiscoverSkills finds all non-hidden-dir skills in a repository at the given +// commit SHA. Hidden-dir skills are excluded; use DiscoverSkillsWithOptions to +// retrieve all skills including those in hidden directories. +func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { + all, err := DiscoverSkillsWithOptions(client, host, owner, repo, commitSHA, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s/%s\n"+ + " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ + " This repository may be a curated list rather than a skills publisher", + owner, repo, + ) + } + return skills, nil +} + +// DiscoverSkillsWithOptions finds all skills in a repository at the given +// commit SHA, with configurable discovery behavior. +func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA)) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch repository tree: %w", err) + } + + if tree.Truncated { + return nil, &TreeTooLargeError{Owner: owner, Repo: repo} + } + + treeSHAs := make(map[string]string) + for _, entry := range tree.Tree { + if entry.Type == "tree" { + treeSHAs[entry.Path] = entry.SHA + } + } + + seen := make(map[string]bool) + var matches []skillMatch + for _, entry := range tree.Tree { + if entry.Type != "blob" { + continue + } + m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } + if m == nil { + continue + } + if seen[m.skillDir] { + continue + } + seen[m.skillDir] = true + matches = append(matches, *m) + } + + if len(matches) == 0 { + return nil, fmt.Errorf( + "no skills found in %s/%s\n"+ + " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " {prefix}/skills/*/SKILL.md, {prefix}/skills/{scope}/*/SKILL.md,\n"+ + " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ + " This repository may be a curated list rather than a skills publisher", + owner, repo, + ) + } + + var skills []Skill + for _, m := range matches { + skills = append(skills, Skill{ + Name: m.name, + Namespace: m.namespace, + Path: m.skillDir, + BlobSHA: m.entry.SHA, + TreeSHA: treeSHAs[m.skillDir], + Convention: m.convention, + }) + } + + sort.SliceStable(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + + return skills, nil +} + +// fetchDescription fetches and parses the frontmatter description for a skill. +func fetchDescription(client *api.Client, host, owner, repo string, skill *Skill) string { + if skill.BlobSHA == "" { + return "" + } + content, err := FetchBlob(client, host, owner, repo, skill.BlobSHA) + if err != nil { + return "" + } + result, err := frontmatter.Parse(content) + if err != nil { + return "" + } + return result.Metadata.Description +} + +// FetchDescriptionsConcurrent fetches descriptions with bounded concurrency. +func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, skills []Skill, onProgress func(done, total int)) { + total := 0 + for _, s := range skills { + if s.Description == "" { + total++ + } + } + if total == 0 { + return + } + + const maxWorkers = 10 + var wg sync.WaitGroup + var done atomic.Int32 + + jobs := make(chan *Skill) + + workers := min(maxWorkers, total) + for range workers { + wg.Go(func() { + for s := range jobs { + s.Description = fetchDescription(client, host, owner, repo, s) + + d := int(done.Add(1)) + if onProgress != nil { + onProgress(d, total) + } + } + }) + } + + for i := range skills { + if skills[i].Description == "" { + jobs <- &skills[i] + } + } + close(jobs) + wg.Wait() +} + +// DiscoverSkillByPathOptions controls optional behavior for DiscoverSkillByPathWithOptions. +type DiscoverSkillByPathOptions struct { + SkipDescription bool +} + +// DiscoverSkillByPath looks up a single skill by its exact path in the repository. +func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skillPath string) (*Skill, error) { + return DiscoverSkillByPathWithOptions(client, host, owner, repo, commitSHA, skillPath, DiscoverSkillByPathOptions{}) +} + +// DiscoverSkillByPathWithOptions looks up a single skill by its exact path in +// the repository, applying the given options. +func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commitSHA, skillPath string, opts DiscoverSkillByPathOptions) (*Skill, error) { + skillPath = strings.TrimSuffix(skillPath, "/SKILL.md") + skillPath = strings.TrimSuffix(skillPath, "/") + + skillName := path.Base(skillPath) + if !validateName(skillName) { + return nil, fmt.Errorf("invalid skill name %q", skillName) + } + + parentPath := path.Dir(skillPath) + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(parentPath), commitSHA) + + var contents []struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Type string `json:"type"` + } + if err := client.REST(host, "GET", apiPath, nil, &contents); err != nil { + return nil, fmt.Errorf("path %q not found in %s/%s: %w", parentPath, owner, repo, err) + } + + var treeSHA string + for _, entry := range contents { + if entry.Name == skillName && entry.Type == "dir" { + treeSHA = entry.SHA + break + } + } + if treeSHA == "" { + return nil, fmt.Errorf("skill directory %q not found in %s/%s", skillPath, owner, repo) + } + + skillTreePath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) + var skillTree treeResponse + if err := client.REST(host, "GET", skillTreePath, nil, &skillTree); err != nil { + return nil, fmt.Errorf("could not read skill directory: %w", err) + } + + var blobSHA string + for _, entry := range skillTree.Tree { + if entry.Path == "SKILL.md" && entry.Type == "blob" { + blobSHA = entry.SHA + break + } + } + if blobSHA == "" { + return nil, fmt.Errorf("no SKILL.md found in %s", skillPath) + } + + var namespace, convention string + parts := strings.Split(skillPath, "/") + for i, p := range parts { + if p != "skills" { + continue + } + + // Plugin convention: .../plugins//skills/ + if i >= 2 && parts[i-2] == "plugins" { + namespace = parts[i-1] + convention = "plugins" + break + } + + // Namespaced skill convention: .../skills// + afterSkills := parts[i+1:] + if len(afterSkills) >= 2 { + namespace = afterSkills[0] + } + break + } + + skill := &Skill{ + Name: skillName, + Namespace: namespace, + Convention: convention, + Path: skillPath, + BlobSHA: blobSHA, + TreeSHA: treeSHA, + } + + if !opts.SkipDescription { + skill.Description = fetchDescription(client, host, owner, repo, skill) + } + + return skill, nil +} + +// DiscoverSkillFiles returns all file paths belonging to a skill directory +// by fetching the skill's subtree directly using its tree SHA. +func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch skill tree: %w", err) + } + + if tree.Truncated { + // Recursive fetch was truncated. Fall back to walking subtrees individually. + return walkTree(client, host, owner, repo, treeSHA, skillPath, 0) + } + + var files []SkillFile + for _, entry := range tree.Tree { + if entry.Type == "blob" { + files = append(files, SkillFile{ + Path: skillPath + "/" + entry.Path, + SHA: entry.SHA, + Size: entry.Size, + }) + } + } + + return files, nil +} + +// ListSkillFiles returns all files in a skill directory as public SkillFile +// structs with paths relative to the skill root. +func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]SkillFile, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch skill tree: %w", err) + } + + if tree.Truncated { + // Fall back to non-recursive traversal when the tree is too large. + return walkTree(client, host, owner, repo, treeSHA, "", 0) + } + + var files []SkillFile + for _, entry := range tree.Tree { + if entry.Type == "blob" { + files = append(files, SkillFile{ + Path: entry.Path, + SHA: entry.SHA, + Size: entry.Size, + }) + } + } + return files, nil +} + +// maxTreeDepth bounds the recursion in walkTree to prevent unbounded +// API calls on deeply nested repositories. +const maxTreeDepth = 20 + +// walkTree enumerates files by fetching each tree level individually, +// avoiding the truncation limit of the recursive tree API. Recursion +// depth is bounded by maxTreeDepth to prevent unbounded API calls. +func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth int) ([]SkillFile, error) { + if depth > maxTreeDepth { + return nil, fmt.Errorf("tree depth exceeds %d levels at %s", maxTreeDepth, prefix) + } + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch tree %s: %w", prefix, err) + } + + var files []SkillFile + for _, entry := range tree.Tree { + entryPath := entry.Path + if prefix != "" { + entryPath = prefix + "/" + entry.Path + } + switch entry.Type { + case "blob": + files = append(files, SkillFile{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) + case "tree": + sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath, depth+1) + if err != nil { + return nil, err + } + files = append(files, sub...) + } + } + return files, nil +} + +// FetchBlob retrieves the content of a blob by SHA. +func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) + var resp struct { + SHA string `json:"sha"` + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return "", fmt.Errorf("could not fetch blob: %w", err) + } + + if resp.Encoding != "base64" { + return "", fmt.Errorf("unexpected blob encoding: %s", resp.Encoding) + } + + // GitHub API returns base64 with embedded newlines; use the StdEncoding + // decoder via a reader to handle them transparently. + decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(resp.Content))) + if err != nil { + return "", fmt.Errorf("could not decode blob content: %w", err) + } + + return string(decoded), nil +} + +// DiscoverLocalSkills finds non-hidden-dir skills in a local directory using +// the same conventions as remote discovery. Hidden-dir skills are excluded; use +// DiscoverLocalSkillsWithOptions to retrieve all skills including those in +// hidden directories. +func DiscoverLocalSkills(dir string) ([]Skill, error) { + all, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s\n"+ + " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ + " skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md", + dir, + ) + } + return skills, nil +} + +// DiscoverLocalSkillsWithOptions finds skills in a local directory using the +// same conventions as remote discovery, with configurable discovery behavior. +func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("could not resolve path: %w", err) + } + + info, err := os.Stat(absDir) + if err != nil { + return nil, fmt.Errorf("could not access %s: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", dir) + } + + if _, err := os.Stat(filepath.Join(absDir, "SKILL.md")); err == nil { + skill, err := localSkillFromDir(absDir) + if err != nil { + return nil, err + } + skill.Path = "." + return []Skill{*skill}, nil + } + + var skills []Skill + seen := make(map[string]bool) + + err = filepath.Walk(absDir, func(p string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + // Skip symlinks to avoid following links outside the source tree. + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + if info.IsDir() || info.Name() != "SKILL.md" { + return nil + } + + relPath, relErr := filepath.Rel(absDir, p) + if relErr != nil { + return relErr + } + relPath = filepath.ToSlash(relPath) + + entry := treeEntry{Path: relPath, Type: "blob"} + m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } + if m == nil { + return nil + } + if seen[m.skillDir] { + return nil + } + seen[m.skillDir] = true + + skill, skillErr := localSkillFromDir(filepath.Join(absDir, filepath.FromSlash(m.skillDir))) + if skillErr != nil { + return nil //nolint:nilerr // intentionally skip files that aren't valid skills + } + skill.Path = m.skillDir + skill.Namespace = m.namespace + skill.Convention = m.convention + skills = append(skills, *skill) + return nil + }) + if err != nil { + return nil, fmt.Errorf("could not walk directory: %w", err) + } + + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s\n"+ + " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ + " skills/{scope}/*/SKILL.md, {prefix}/skills/*/SKILL.md,\n"+ + " {prefix}/skills/{scope}/*/SKILL.md, */SKILL.md, or\n"+ + " plugins/*/skills/*/SKILL.md", + dir, + ) + } + + return skills, nil +} + +func localSkillFromDir(dir string) (*Skill, error) { + skillFile := filepath.Join(dir, "SKILL.md") + data, err := os.ReadFile(skillFile) + if err != nil { + return nil, fmt.Errorf("could not read %s: %w", skillFile, err) + } + + name := filepath.Base(dir) + var description string + + result, parseErr := frontmatter.Parse(string(data)) + if parseErr == nil { + if result.Metadata.Name != "" { + name = result.Metadata.Name + } + description = result.Metadata.Description + } + + if !validateName(name) { + return nil, fmt.Errorf("invalid skill name %q in %s", name, dir) + } + + return &Skill{ + Name: name, + Description: description, + Path: filepath.Base(dir), + }, nil +} + +// validateName checks if a skill name is safe for use (filesystem-safe). +func validateName(name string) bool { + if len(name) == 0 || len(name) > 64 { + return false + } + if strings.Contains(name, "/") || strings.Contains(name, "..") { + return false + } + return safeNamePattern.MatchString(name) +} + +// hasHiddenSegment reports whether any path component starts with a dot. +func hasHiddenSegment(p string) bool { + for _, seg := range strings.Split(p, "/") { + if strings.HasPrefix(seg, ".") { + return true + } + } + return false +} + +// hasPluginsAncestor reports whether any path component is "plugins". +func hasPluginsAncestor(p string) bool { + for _, seg := range strings.Split(p, "/") { + if seg == "plugins" { + return true + } + } + return false +} + +// IsSpecCompliant checks if a skill name matches the strict agentskills.io spec. +func IsSpecCompliant(name string) bool { + if len(name) == 0 || len(name) > 64 { + return false + } + if strings.Contains(name, "--") { + return false + } + return specNamePattern.MatchString(name) +} diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go new file mode 100644 index 00000000000..8d1cff8c93e --- /dev/null +++ b/internal/skills/discovery/discovery_test.go @@ -0,0 +1,1803 @@ +package discovery + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallName(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "plain skill", + skill: Skill{Name: "code-review"}, + wantName: "code-review", + }, + { + name: "namespaced skill", + skill: Skill{Name: "issue-triage", Namespace: "monalisa"}, + wantName: "monalisa/issue-triage", + }, + { + name: "plugin skill with namespace", + skill: Skill{Name: "pr-summary", Namespace: "hubot", Convention: "plugins"}, + wantName: "hubot/pr-summary", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.InstallName()) + }) + } +} + +func TestMatchSkillConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "plugin namespace", + path: "plugins/hubot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantNamespace: "hubot", + wantConvention: "plugins", + }, + { + name: "namespaced skill", + path: "skills/monalisa/issue-triage/SKILL.md", + wantName: "issue-triage", + wantNamespace: "monalisa", + wantConvention: "skills-namespaced", + }, + { + name: "regular skill", + path: "skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "skills", + }, + { + name: "non-SKILL.md file", + path: "skills/code-review/README.md", + wantNil: true, + }, + { + name: "plugin skill from different author", + path: "plugins/monalisa/skills/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "plugins", + }, + { + name: "root convention single-skill repo", + path: "code-review/SKILL.md", + wantName: "code-review", + wantConvention: "root", + }, + { + name: "root convention excludes skills dir", + path: "skills/SKILL.md", + wantNil: true, + }, + { + name: "root convention excludes dot-prefixed", + path: ".hidden/SKILL.md", + wantNil: true, + }, + { + name: "nested skills directory", + path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", + wantName: "terraform-style-guide", + wantConvention: "skills", + }, + { + name: "deeply nested skills directory", + path: "a/b/c/skills/my-skill/SKILL.md", + wantName: "my-skill", + wantConvention: "skills", + }, + { + name: "nested namespaced skills directory", + path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md", + wantName: "terraform-style-guide", + wantNamespace: "hashicorp", + wantConvention: "skills-namespaced", + }, + { + name: "single prefix before skills directory", + path: "packer/skills/packer-builder/SKILL.md", + wantName: "packer-builder", + wantConvention: "skills", + }, + { + name: "root-level skills still has priority", + path: "skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "skills", + }, + { + name: "nested skills dir itself is not a skill", + path: "terraform/skills/SKILL.md", + wantNil: true, + }, + { + name: "nested skills under hidden dir excluded", + path: ".claude/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "nested plugins skills not matched as plain skills", + path: "vendor/plugins/hubot/skills/pr-summary/SKILL.md", + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchSkillConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) + } +} + +func TestMatchHiddenDirConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "claude skills directory", + path: ".claude/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", + }, + { + name: "agents skills directory", + path: ".agents/skills/git-commit/SKILL.md", + wantName: "git-commit", + wantConvention: "hidden-dir", + }, + { + name: "github skills directory", + path: ".github/skills/issue-triage/SKILL.md", + wantName: "issue-triage", + wantConvention: "hidden-dir", + }, + { + name: "copilot skills directory", + path: ".copilot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantConvention: "hidden-dir", + }, + { + name: "namespaced hidden dir skill", + path: ".claude/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, + { + name: "nested hidden dir skills directory", + path: "foo/bar/.claude/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", + }, + { + name: "nested hidden dir namespaced skill", + path: "foo/bar/.claude/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, + { + name: "not a SKILL.md file", + path: ".claude/skills/code-review/README.md", + wantNil: true, + }, + { + name: "too shallow - just hidden dir and SKILL.md", + path: ".claude/SKILL.md", + wantNil: true, + }, + { + name: "no skills subdirectory", + path: ".claude/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden dir does not match", + path: "visible/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden-namespaced dir does not match", + path: "visible/skills/monalisa/code-review/SKILL.md", + wantNil: true, + }, + { + name: "hidden dir with nested skills directory", + path: ".claude/nested/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", + }, + { + name: "hidden dir with nested namespaced skills directory", + path: ".claude/nested/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, + { + name: "invalid skill name", + path: ".claude/skills/../SKILL.md", + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchHiddenDirConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) + } +} + +func TestHasHiddenDirSkills(t *testing.T) { + tests := []struct { + name string + skills []Skill + want bool + }{ + { + name: "empty list", + skills: nil, + want: false, + }, + { + name: "only standard skills", + skills: []Skill{{Convention: "skills"}, {Convention: "root"}}, + want: false, + }, + { + name: "has hidden-dir skill", + skills: []Skill{{Convention: "skills"}, {Convention: "hidden-dir"}}, + want: true, + }, + { + name: "has hidden-dir-namespaced skill", + skills: []Skill{{Convention: "hidden-dir-namespaced"}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, HasHiddenDirSkills(tt.skills)) + }) + } +} + +func TestDisplayNameHiddenDir(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "hidden-dir skill", + skill: Skill{Name: "code-review", Convention: "hidden-dir"}, + wantName: "[hidden-dir] code-review", + }, + { + name: "hidden-dir-namespaced skill", + skill: Skill{Name: "code-review", Namespace: "monalisa", Convention: "hidden-dir-namespaced"}, + wantName: "[hidden-dir] monalisa/code-review", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.DisplayName()) + }) + } +} + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "too long", input: strings.Repeat("a", 65), want: false}, + {name: "max length is valid", input: strings.Repeat("a", 64), want: true}, + {name: "contains slash", input: "foo/bar", want: false}, + {name: "contains dotdot", input: "foo..bar", want: false}, + {name: "starts with dot", input: ".hidden", want: false}, + {name: "simple name", input: "code-review", want: true}, + {name: "with dots and underscores", input: "octocat_helper.v2", want: true}, + {name: "uppercase allowed", input: "Octocat", want: true}, + {name: "single char", input: "a", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, validateName(tt.input)) + }) + } +} + +func TestIsSpecCompliant(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "consecutive hyphens", input: "code--review", want: false}, + {name: "uppercase rejected", input: "Octocat", want: false}, + {name: "starts with hyphen", input: "-octocat", want: false}, + {name: "ends with hyphen", input: "octocat-", want: false}, + {name: "valid lowercase with hyphens", input: "issue-triage", want: true}, + {name: "valid single char", input: "a", want: true}, + {name: "valid with numbers", input: "copilot4", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsSpecCompliant(tt.input)) + }) + } +} + +func TestIsFullyQualifiedRef(t *testing.T) { + tests := []struct { + name string + ref string + want bool + }{ + {name: "branch ref", ref: "refs/heads/main", want: true}, + {name: "tag ref", ref: "refs/tags/v1.0", want: true}, + {name: "short branch name", ref: "main", want: false}, + {name: "short tag name", ref: "v1.0", want: false}, + {name: "bare SHA", ref: "abc123def456", want: false}, + {name: "empty", ref: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsFullyQualifiedRef(tt.ref)) + }) + } +} + +func TestShortRef(t *testing.T) { + tests := []struct { + name string + ref string + want string + }{ + {name: "branch ref", ref: "refs/heads/main", want: "main"}, + {name: "tag ref", ref: "refs/tags/v1.0", want: "v1.0"}, + {name: "short name passthrough", ref: "main", want: "main"}, + {name: "bare SHA passthrough", ref: "abc123", want: "abc123"}, + {name: "empty passthrough", ref: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ShortRef(tt.ref)) + }) + } +} + +func TestResolveRef(t *testing.T) { + tests := []struct { + name string + version string + stubs func(*httpmock.Registry) + wantRef string + wantSHA string + wantErr string + }{ + { + name: "short name resolves as branch first", + version: "main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "branch-sha", + }, + { + name: "short name falls back to tag when branch not found", + version: "v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v1.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "abc123", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v1.0", + wantSHA: "abc123", + }, + { + name: "short name resolves annotated tag", + version: "v2.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v2.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "real-commit-sha"}, + })) + }, + wantRef: "refs/tags/v2.0", + wantSHA: "real-commit-sha", + }, + { + name: "short name falls back to commit SHA", + version: "deadbeef", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/deadbeef"), + httpmock.JSONResponse(map[string]interface{}{"sha": "deadbeef"})) + }, + wantRef: "deadbeef", + wantSHA: "deadbeef", + }, + { + name: "short name not found anywhere", + version: "nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `ref "nonexistent" not found as branch, tag, or commit in monalisa/octocat-skills`, + }, + { + name: "branch wins over tag with same short name", + version: "release", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/release"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + // tag stub is not registered because branch succeeds first + }, + wantRef: "refs/heads/release", + wantSHA: "branch-sha", + }, + { + name: "fully qualified tag ref resolved directly", + version: "refs/tags/v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-sha", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v1.0", + wantSHA: "tag-sha", + }, + { + name: "fully qualified branch ref resolved directly", + version: "refs/heads/feature", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/feature"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "feature-sha"}, + })) + }, + wantRef: "refs/heads/feature", + wantSHA: "feature-sha", + }, + { + name: "fully qualified tag ref not found", + version: "refs/tags/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `tag "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "fully qualified branch ref not found", + version: "refs/heads/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `branch "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "no version uses latest release with fully qualified ref", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": "v3.0"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "release-sha", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v3.0", + wantSHA: "release-sha", + }, + { + name: "no version falls back to default branch with fully qualified ref", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "branch-sha", + }, + { + name: "annotated tag dereference failure", + version: "refs/tags/v4.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not dereference annotated tag", + }, + { + name: "no version with server error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(500, "internal server error")) + }, + wantErr: "could not fetch latest release", + }, + { + name: "no version with forbidden error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: "could not fetch latest release", + }, + { + name: "empty tag_name in latest release falls back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "fallback-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "fallback-sha", + }, + { + name: "empty default_branch returns error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": ""})) + }, + wantErr: "could not determine default branch", + }, + { + name: "short name with server error on branch lookup does not fall through", + version: "main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: `branch "main" not found in monalisa/octocat-skills`, + }, + { + name: "short name with forbidden error on branch lookup does not fall through", + version: "develop", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/develop"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: `branch "develop" not found in monalisa/octocat-skills`, + }, + { + name: "short name with server error on tag lookup does not fall through", + version: "v5.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v5.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v5.0"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: `tag "v5.0" not found in monalisa/octocat-skills`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + ref, err := ResolveRef(client, "github.com", "monalisa", "octocat-skills", tt.version) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRef, ref.Ref) + assert.Equal(t, tt.wantSHA, ref.SHA) + }) + } +} + +func TestFetchBlob(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantErr string + want string + }{ + { + name: "decodes base64 content", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "base64", "content": "SGVsbG8gV29ybGQ=", + })) + }, + want: "Hello World", + }, + { + name: "rejects non-base64 encoding", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "utf-8", "content": "raw", + })) + }, + wantErr: "unexpected blob encoding: utf-8", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch blob", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + got, err := FetchBlob(client, "github.com", "monalisa", "octocat-skills", "abc") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFetchRepoVisibility(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + want RepoVisibility + wantErr string + }{ + { + name: "public repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + })) + }, + want: RepoVisibilityPublic, + }, + { + name: "private repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "private", + })) + }, + want: RepoVisibilityPrivate, + }, + { + name: "internal repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "internal", + })) + }, + want: RepoVisibilityInternal, + }, + { + name: "unknown visibility", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "cool-visibility", + })) + }, + wantErr: `unknown repository visibility: "cool-visibility"`, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "HTTP 500", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + got, err := FetchRepoVisibility(client, "github.com", "monalisa", "octocat-skills") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDiscoverSkills(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills from tree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/issue-triage", "type": "tree", "sha": "tree-sha-2"}, + {"path": "skills/issue-triage/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "truncated tree returns error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": true, "tree": []map[string]interface{}{}, + })) + }, + wantErr: "too large", + }, + { + name: "no skills found", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no skills found", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch repository tree", + }, + { + name: "deduplicates skills from same directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review"}, + }, + { + name: "discovers skills in nested skills directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "terraform/code-generation/skills/terraform-style-guide", "type": "tree", "sha": "tree-sha-1"}, + {"path": "terraform/code-generation/skills/terraform-style-guide/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "terraform/code-generation/skills/terraform-test", "type": "tree", "sha": "tree-sha-2"}, + {"path": "terraform/code-generation/skills/terraform-test/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"terraform-style-guide", "terraform-test"}, + }, + { + name: "discovers mixed root-level and nested skills", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "terraform/skills/tf-lint", "type": "tree", "sha": "tree-sha-2"}, + {"path": "terraform/skills/tf-lint/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review", "tf-lint"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkills(client, "github.com", "monalisa", "octocat-skills", "abc123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + +func TestDiscoverSkillsWithOptions(t *testing.T) { + hiddenDirTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": ".claude/skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": ".claude/skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".agents/skills/git-commit", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".agents/skills/git-commit/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + mixedTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/standard-skill", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/standard-skill/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + } + + nestedHiddenTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "foo/bar/.claude/skills/hidden-skill", "type": "tree", "sha": "tree-sha-1"}, + {"path": "foo/bar/.claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "foo/bar/.claude/nested/skills/deep-hidden-skill", "type": "tree", "sha": "tree-sha-2"}, + {"path": "foo/bar/.claude/nested/skills/deep-hidden-skill/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + } + + emptyTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + tests := []struct { + name string + tree map[string]interface{} + wantSkills []string + wantErr string + }{ + { + name: "returns hidden-dir skills", + tree: hiddenDirTree, + wantSkills: []string{"code-review", "git-commit"}, + }, + { + name: "mixed tree returns all skills", + tree: mixedTree, + wantSkills: []string{"hidden-skill", "standard-skill"}, + }, + { + name: "nested hidden-dir tree returns hidden skill", + tree: nestedHiddenTree, + wantSkills: []string{"deep-hidden-skill", "hidden-skill"}, + }, + { + name: "no skills at all", + tree: emptyTree, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(tt.tree)) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkillsWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + +func TestDiscoverSkillByPath(t *testing.T) { + tests := []struct { + name string + skillPath string + stubs func(*httpmock.Registry) + wantName string + wantNS string + wantConvention string + wantErr string + }{ + { + name: "discovers skill by path", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "namespaced path sets namespace", + skillPath: "skills/monalisa/issue-triage", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills%2Fmonalisa"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "issue-triage", "path": "skills/monalisa/issue-triage", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "issue-triage", + wantNS: "monalisa", + }, + { + name: "parent path with spaces is URL encoded", + skillPath: "my skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/my%20skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "my skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "strips trailing SKILL.md from path", + skillPath: "skills/code-review/SKILL.md", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "invalid skill name", + skillPath: "skills/.hidden-skill", + wantErr: "invalid skill name", + }, + { + name: "skill directory not found", + skillPath: "skills/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "other-skill", "path": "skills/other-skill", "sha": "tree-sha", "type": "dir"}, + })) + }, + wantErr: "skill directory", + }, + { + name: "no SKILL.md in directory", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no SKILL.md found", + }, + { + name: "deeply nested path discovers skill", + skillPath: "terraform/code-generation/skills/terraform-style-guide", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "terraform-style-guide", "path": "terraform/code-generation/skills/terraform-style-guide", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "terraform-style-guide", + }, + { + name: "deeply nested namespaced path sets namespace", + skillPath: "terraform/code-generation/skills/hashicorp/terraform-style-guide", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills%2Fhashicorp"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "terraform-style-guide", "path": "terraform/code-generation/skills/hashicorp/terraform-style-guide", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "terraform-style-guide", + wantNS: "hashicorp", + }, + { + name: "plugins path sets namespace and convention", + skillPath: "plugins/hubot/skills/pr-summary", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/plugins%2Fhubot%2Fskills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "pr-summary", "path": "plugins/hubot/skills/pr-summary", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "pr-summary", + wantNS: "hubot", + wantConvention: "plugins", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skill, err := DiscoverSkillByPath(client, "github.com", "monalisa", "octocat-skills", "abc123", tt.skillPath) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, skill.Name) + assert.Equal(t, tt.wantNS, skill.Namespace) + if tt.wantConvention != "" { + assert.Equal(t, tt.wantConvention, skill.Convention) + } + }) + } +} + +func TestDiscoverSkillByPathWithOptionsSkipsDescription(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + skill, err := DiscoverSkillByPathWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", "skills/code-review", DiscoverSkillByPathOptions{SkipDescription: true}) + + require.NoError(t, err) + assert.Equal(t, "code-review", skill.Name) + assert.Empty(t, skill.Description) +} + +func TestDiscoverLocalSkills(t *testing.T) { + tests := []struct { + name string + createDir bool + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills in skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "single skill at root", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: root-skill + --- + # Root + `)), 0o644)) + }, + wantSkills: []string{"root-skill"}, + }, + { + name: "no skills found", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Not a skill"), 0o644)) + }, + wantErr: "no skills found", + }, + { + name: "nonexistent directory", + setup: func(t *testing.T, dir string) {}, + wantErr: "could not access", + }, + { + name: "discovers skills in nested skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"terraform-style-guide", "terraform-test"} { + skillDir := filepath.Join(dir, "terraform", "code-generation", "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"terraform-style-guide", "terraform-test"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + if tt.createDir { + require.NoError(t, os.MkdirAll(dir, 0o755)) + } + tt.setup(t, dir) + + skills, err := DiscoverLocalSkills(dir) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + +func TestDiscoverLocalSkillsWithOptions(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "returns hidden dir skills", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# code-review"), 0o644)) + }, + wantSkills: []string{"code-review"}, + }, + { + name: "mixed standard and hidden returns all", + setup: func(t *testing.T, dir string) { + t.Helper() + for _, p := range []string{"skills/standard", ".agents/skills/hidden"} { + skillDir := filepath.Join(dir, filepath.FromSlash(p)) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + name := filepath.Base(p) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"standard", "hidden"}, + }, + { + name: "nested hidden dir returns skill", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "foo", "bar", ".claude", "skills", "hidden") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# hidden"), 0o644)) + + deepDir := filepath.Join(dir, "foo", "bar", ".claude", "nested", "skills", "deep-hidden") + require.NoError(t, os.MkdirAll(deepDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(deepDir, "SKILL.md"), []byte("# deep-hidden"), 0o644)) + }, + wantSkills: []string{"deep-hidden", "hidden"}, + }, + { + name: "no skills at all", + setup: func(t *testing.T, _ string) { t.Helper() }, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + + skills, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + +func TestMatchesSkillPath(t *testing.T) { + tests := []struct { + name string + path string + wantName string + }{ + {name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review"}, + {name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage"}, + {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"}, + {name: "non-skill file", path: "README.md", wantName: ""}, + {name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""}, + {name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, MatchesSkillPath(tt.path)) + }) + } +} + +func TestMatchSkillPath(t *testing.T) { + tests := []struct { + name string + path string + wantName string + wantNamespace string + }{ + {name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""}, + {name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"}, + {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"}, + {name: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""}, + {name: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, + {name: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, + {name: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, + {name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: ""}, + {name: "nested namespaced convention", path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: "hashicorp"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, namespace := MatchSkillPath(tt.path) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantNamespace, namespace) + }) + } +} + +func TestIsSkillPath(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {name: "empty string", path: "", want: false}, + {name: "plain skill name", path: "git-commit", want: false}, + {name: "bare SKILL.md", path: "SKILL.md", want: false}, + {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, + {name: "starts with skills/", path: "skills/code-review", want: true}, + {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, + {name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true}, + {name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true}, + {name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true}, + {name: "arbitrary nested skill path", path: "packages/agent-skills/netsuite-ai-connector-instructions", want: true}, + {name: "arbitrary nested skill path with trailing slash", path: "skills-catalog/matlab-core/matlab-debugging/", want: true}, + {name: "name containing skills substring", path: "myskills", want: false}, + {name: "namespaced skill name", path: "monalisa/code-review", want: false}, + {name: "namespaced path", path: "skills/monalisa/issue-triage", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsSkillPath(tt.path)) + }) + } +} + +func TestDiscoverSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns files with skill path prefix", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts/setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treesub"}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md", "skills/code-review/scripts/setup.sh"}, + }, + { + name: "truncated tree falls back to walkTree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := DiscoverSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123", "skills/code-review") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestListSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns relative paths", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "sha2", "size": 20}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "prompt.txt"}, + }, + { + name: "truncated tree falls back to walkTree with nested subtree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + // walkTree fetches the top-level tree non-recursively + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts", "type": "tree", "sha": "subtree1"}, + }, + })) + // walkTree recurses into the "scripts" subtree + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/subtree1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "subtree1", + "tree": []map[string]interface{}{ + {"path": "setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "scripts/setup.sh"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := ListSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestFetchDescriptionsConcurrent(t *testing.T) { + tests := []struct { + name string + skills []Skill + stubs func(*httpmock.Registry) + wantDescs []string + }{ + { + name: "fetches descriptions for skills without one", + skills: []Skill{ + {Name: "code-review", BlobSHA: "blob1"}, + {Name: "issue-triage", Description: "already set"}, + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob1", "encoding": "base64", + "content": "LS0tCm5hbWU6IGNvZGUtcmV2aWV3CmRlc2NyaXB0aW9uOiBSZXZpZXdzIFBScwotLS0KIyBUZXN0", + })) + }, + wantDescs: []string{"Reviews PRs", "already set"}, + }, + { + name: "no-op when all descriptions set", + skills: []Skill{ + {Name: "code-review", Description: "set"}, + }, + stubs: func(reg *httpmock.Registry) {}, + wantDescs: []string{"set"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + FetchDescriptionsConcurrent(client, "github.com", "monalisa", "octocat-skills", tt.skills, nil) + var descs []string + for _, s := range tt.skills { + descs = append(descs, s.Description) + } + assert.Equal(t, tt.wantDescs, descs) + }) + } +} diff --git a/internal/skills/frontmatter/frontmatter.go b/internal/skills/frontmatter/frontmatter.go new file mode 100644 index 00000000000..87ad067a0a8 --- /dev/null +++ b/internal/skills/frontmatter/frontmatter.go @@ -0,0 +1,149 @@ +package frontmatter + +import ( + "bytes" + "fmt" + "strings" + + "github.com/cli/cli/v2/internal/skills/source" + "gopkg.in/yaml.v3" +) + +const delimiter = "---" + +// Metadata represents the parsed YAML frontmatter of a SKILL.md file. +type Metadata struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + License string `yaml:"license,omitempty"` + Meta map[string]interface{} `yaml:"metadata,omitempty"` +} + +// ParseResult contains the parsed frontmatter and remaining body. +type ParseResult struct { + Metadata Metadata + Body string + RawYAML map[string]interface{} +} + +// Parse extracts YAML frontmatter from a SKILL.md file. +// Frontmatter is delimited by --- on its own lines. +func Parse(content string) (*ParseResult, error) { + trimmed := strings.TrimLeft(content, "\r\n") + if !strings.HasPrefix(trimmed, delimiter) { + return &ParseResult{Body: content}, nil + } + + rest := trimmed[len(delimiter):] + rest = strings.TrimLeft(rest, "\r\n") + endIdx := strings.Index(rest, "\n"+delimiter) + if endIdx == -1 { + return &ParseResult{Body: content}, nil + } + + yamlContent := rest[:endIdx] + body := rest[endIdx+len("\n"+delimiter):] + body = strings.TrimLeft(body, "\r\n") + + var rawYAML map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &rawYAML); err != nil { + return nil, fmt.Errorf("invalid frontmatter YAML: %w", err) + } + + var meta Metadata + if err := yaml.Unmarshal([]byte(yamlContent), &meta); err != nil { + return nil, fmt.Errorf("invalid frontmatter YAML: %w", err) + } + + return &ParseResult{ + Metadata: meta, + Body: body, + RawYAML: rawYAML, + }, nil +} + +// InjectGitHubMetadata adds GitHub tracking metadata to the spec-defined +// "metadata" map in frontmatter. Keys are prefixed with "github-" to avoid +// collisions with other tools' metadata. +// pinnedRef is the user's explicit --pin value; empty string means unpinned. +// skillPath is the skill's source path in the repo (e.g. "skills/author/my-skill"). +func InjectGitHubMetadata(content string, host, owner, repo, ref, treeSHA, pinnedRef, skillPath string) (string, error) { + result, err := Parse(content) + if err != nil { + return "", err + } + + if result.RawYAML == nil { + result.RawYAML = make(map[string]interface{}) + } + + meta, _ := result.RawYAML["metadata"].(map[string]interface{}) + if meta == nil { + meta = make(map[string]interface{}) + } + delete(meta, "github-owner") + meta["github-repo"] = source.BuildRepoURL(host, owner, repo) + meta["github-ref"] = ref + delete(meta, "github-sha") + meta["github-tree-sha"] = treeSHA + meta["github-path"] = skillPath + if pinnedRef != "" { + meta["github-pinned"] = pinnedRef + } else { + delete(meta, "github-pinned") + } + result.RawYAML["metadata"] = meta + + return Serialize(result.RawYAML, result.Body) +} + +// InjectLocalMetadata adds local-source tracking metadata to frontmatter. +// sourcePath is the absolute path to the source skill directory. +func InjectLocalMetadata(content string, sourcePath string) (string, error) { + result, err := Parse(content) + if err != nil { + return "", err + } + + if result.RawYAML == nil { + result.RawYAML = make(map[string]interface{}) + } + + meta, _ := result.RawYAML["metadata"].(map[string]interface{}) + if meta == nil { + meta = make(map[string]interface{}) + } + delete(meta, "github-owner") + delete(meta, "github-repo") + delete(meta, "github-ref") + delete(meta, "github-sha") + delete(meta, "github-tree-sha") + delete(meta, "github-pinned") + delete(meta, "github-path") + meta["local-path"] = sourcePath + result.RawYAML["metadata"] = meta + + return Serialize(result.RawYAML, result.Body) +} + +// Serialize writes a frontmatter map and body back to a SKILL.md string. +func Serialize(frontmatter map[string]interface{}, body string) (string, error) { + var buf bytes.Buffer + + yamlBytes, err := yaml.Marshal(frontmatter) + if err != nil { + return "", fmt.Errorf("failed to serialize frontmatter: %w", err) + } + + buf.WriteString(delimiter + "\n") + buf.Write(yamlBytes) + buf.WriteString(delimiter + "\n") + if body != "" { + buf.WriteString(body) + if !strings.HasSuffix(body, "\n") { + buf.WriteString("\n") + } + } + + return buf.String(), nil +} diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go new file mode 100644 index 00000000000..d88811ea2f2 --- /dev/null +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -0,0 +1,255 @@ +package frontmatter + +import ( + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + content string + wantName string + wantDesc string + wantBody string + wantErr bool + }{ + { + name: "valid frontmatter", + content: heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + # Body + `), + wantName: "test-skill", + wantDesc: "A test skill", + wantBody: "# Body\n", + }, + { + name: "no frontmatter", + content: "# Just a markdown file\n", + wantBody: "# Just a markdown file\n", + }, + { + name: "invalid YAML", + content: "---\n: invalid yaml [[\n---\n", + wantErr: true, + }, + { + name: "no closing delimiter", + content: "---\nname: test\n", + wantBody: "---\nname: test\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.content) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, result.Metadata.Name) + assert.Equal(t, tt.wantDesc, result.Metadata.Description) + assert.Equal(t, tt.wantBody, result.Body) + }) + } +} + +func TestInjectGitHubMetadata(t *testing.T) { + tests := []struct { + name string + content string + host string + owner string + repo string + ref string + treeSHA string + pinnedRef string + skillPath string + wantContains []string + wantNotContain []string + }{ + { + name: "injects metadata without pin", + content: heredoc.Doc(` + --- + name: my-skill + description: desc + --- + # Body + `), + host: "github.com", + owner: "monalisa", + repo: "octocat-skills", + ref: "refs/tags/v1.0.0", + treeSHA: "tree456", + pinnedRef: "", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-repo: https://github.com/monalisa/octocat-skills", + "github-ref: refs/tags/v1.0.0", + "github-tree-sha: tree456", + "github-path: skills/my-skill", + "# Body", + }, + wantNotContain: []string{ + "github-owner", + "github-sha", + "github-pinned", + }, + }, + { + name: "injects pinned ref", + content: heredoc.Doc(` + --- + name: my-skill + --- + # Body + `), + host: "github.com", + owner: "monalisa", + repo: "octocat-skills", + ref: "refs/tags/v1.0.0", + treeSHA: "tree", + pinnedRef: "v1.0.0", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-pinned: v1.0.0", + }, + }, + { + name: "injects metadata into content with no frontmatter", + content: "# Body only\n", + host: "github.com", + owner: "monalisa", + repo: "octocat-skills", + ref: "refs/heads/main", + treeSHA: "tree456", + pinnedRef: "", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-repo: https://github.com/monalisa/octocat-skills", + "github-ref: refs/heads/main", + "# Body only", + }, + wantNotContain: []string{"github-owner", "github-sha"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InjectGitHubMetadata(tt.content, tt.host, tt.owner, tt.repo, tt.ref, tt.treeSHA, tt.pinnedRef, tt.skillPath) + require.NoError(t, err) + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNotContain { + assert.NotContains(t, got, s) + } + }) + } +} + +func TestInjectLocalMetadata(t *testing.T) { + tests := []struct { + name string + content string + wantContains []string + wantNotContain []string + }{ + { + name: "strips all github keys and injects local-path", + content: heredoc.Doc(` + --- + name: my-skill + metadata: + github-owner: old + github-repo: old + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: tree456 + github-pinned: v1.0.0 + github-path: skills/my-skill + --- + # Body + `), + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + wantNotContain: []string{"github-owner", "github-repo", "github-ref", "github-sha", "github-tree-sha", "github-pinned", "github-path"}, + }, + { + name: "injects into content with no existing metadata", + content: "# Body only\n", + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InjectLocalMetadata(tt.content, "/home/monalisa/skills/my-skill") + require.NoError(t, err) + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNotContain { + assert.NotContains(t, got, s) + } + }) + } +} + +func TestSerialize(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]interface{} + body string + wantPrefix string + wantSuffix string + wantContains []string + }{ + { + name: "with body", + frontmatter: map[string]interface{}{"name": "test"}, + body: "# Body content", + wantPrefix: "---\n", + wantContains: []string{ + "name: test", + "# Body content", + }, + }, + { + name: "empty body", + frontmatter: map[string]interface{}{"name": "test"}, + body: "", + wantSuffix: "---\n", + }, + { + name: "body without trailing newline gets one added", + frontmatter: map[string]interface{}{"name": "test"}, + body: "# No trailing newline", + wantSuffix: "# No trailing newline\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Serialize(tt.frontmatter, tt.body) + require.NoError(t, err) + if tt.wantPrefix != "" { + assert.True(t, strings.HasPrefix(got, tt.wantPrefix)) + } + if tt.wantSuffix != "" { + assert.True(t, strings.HasSuffix(got, tt.wantSuffix)) + } + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + }) + } +} diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go new file mode 100644 index 00000000000..005681cac54 --- /dev/null +++ b/internal/skills/installer/installer.go @@ -0,0 +1,331 @@ +package installer + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/safepaths" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/lockfile" + "github.com/cli/cli/v2/internal/skills/registry" +) + +// maxConcurrency limits parallel API requests to avoid rate limiting. +const maxConcurrency = 5 + +// Options configures an installation. +type Options struct { + Host string // GitHub API hostname + Owner string + Repo string + Ref string // resolved ref name + SHA string // resolved commit SHA + PinnedRef string // user-supplied --pin value (empty if unpinned) + Skills []discovery.Skill + AgentHost *registry.AgentHost + Scope registry.Scope + Dir string // explicit target directory (overrides AgentHost+Scope) + GitRoot string // git repository root (for project scope) + HomeDir string // user home directory (for user scope) + Client *api.Client + OnProgress func(done, total int) // called after each skill is installed +} + +// Result tracks what was installed. +type Result struct { + Installed []string + Dir string + Warnings []string +} + +type skillResult struct { + name string + err error +} + +// Install fetches and writes skills to the target directory. +func Install(opts *Options) (*Result, error) { + targetDir := opts.Dir + if targetDir == "" { + if opts.AgentHost == nil { + return nil, fmt.Errorf("either Dir or AgentHost must be specified") + } + var err error + targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) + if err != nil { + return nil, err + } + } + + if len(opts.Skills) == 1 { + skill := opts.Skills[0] + if opts.OnProgress != nil { + opts.OnProgress(0, 1) + defer opts.OnProgress(1, 1) + } + if err := installSkill(opts, skill, targetDir); err != nil { + return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) + } + var warnings []string + if err := lockfile.RecordInstall(opts.Host, skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) + } + return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil + } + + total := len(opts.Skills) + if opts.OnProgress != nil { + opts.OnProgress(0, total) + } + + type job struct { + idx int + skill discovery.Skill + } + jobs := make(chan job) + + results := make([]skillResult, total) + var wg sync.WaitGroup + var done atomic.Int32 + + workers := min(maxConcurrency, total) + for range workers { + wg.Go(func() { + for j := range jobs { + err := installSkill(opts, j.skill, targetDir) + results[j.idx] = skillResult{name: j.skill.InstallName(), err: err} + + if opts.OnProgress != nil { + opts.OnProgress(int(done.Add(1)), total) + } + } + }) + } + + for i, s := range opts.Skills { + jobs <- job{idx: i, skill: s} + } + close(jobs) + wg.Wait() + + var installed []string + var warnings []string + var firstErr error + for i, r := range results { + if r.err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("failed to install skill %q: %w", r.name, r.err) + } + continue + } + installed = append(installed, r.name) + skill := opts.Skills[i] + if err := lockfile.RecordInstall(opts.Host, skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) + } + } + + if firstErr != nil { + return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, firstErr + } + + return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, nil +} + +// LocalOptions configures a local directory installation. +type LocalOptions struct { + SourceDir string + Skills []discovery.Skill + AgentHost *registry.AgentHost + Scope registry.Scope + Dir string + GitRoot string + HomeDir string +} + +// InstallLocal copies skills from a local directory to the target install location. +func InstallLocal(opts *LocalOptions) (*Result, error) { + targetDir := opts.Dir + if targetDir == "" { + if opts.AgentHost == nil { + return nil, fmt.Errorf("either Dir or AgentHost must be specified") + } + var err error + targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) + if err != nil { + return nil, err + } + } + + var installed []string + for _, skill := range opts.Skills { + if err := installLocalSkill(opts.SourceDir, skill, targetDir); err != nil { + return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) + } + installed = append(installed, skill.InstallName()) + } + + return &Result{Installed: installed, Dir: targetDir}, nil +} + +func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error { + // Use skill.Name (not InstallName) so skills are always installed flat. + // Most agent clients only discover immediate subdirectories of their + // skills folder and do not find skills nested under namespace directories. + skillDir := filepath.Join(baseDir, skill.Name) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) + } + + srcDir := filepath.Join(sourceRoot, filepath.FromSlash(skill.Path)) + absSource, err := filepath.Abs(srcDir) + if err != nil { + return fmt.Errorf("could not resolve source path: %w", err) + } + + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) + if err != nil { + return fmt.Errorf("could not resolve target path: %w", err) + } + + return filepath.WalkDir(srcDir, func(p string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + if d.IsDir() { + return nil + } + + relPath, err := filepath.Rel(srcDir, p) + if err != nil { + return err + } + + // Defensive: filepath.WalkDir cannot produce traversal paths, but we + // guard against it in case the walk input is ever changed. + safeDest, err := safeSkillDir.Join(relPath) + if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return fmt.Errorf("blocked path traversal in %q", relPath) + } + return fmt.Errorf("could not resolve destination path: %w", err) + } + destPath := safeDest.String() + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + + content, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("could not read %s: %w", p, err) + } + + if filepath.Base(relPath) == "SKILL.md" { + injected, injectErr := frontmatter.InjectLocalMetadata(string(content), absSource) + if injectErr != nil { + return fmt.Errorf("could not inject metadata: %w", injectErr) + } + content = []byte(injected) + } + + return os.WriteFile(destPath, content, 0o644) + }) +} + +func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { + // Use skill.Name (not InstallName) for a flat directory layout. + skillDir := filepath.Join(baseDir, skill.Name) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) + } + + files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) + if err != nil { + return fmt.Errorf("could not list skill files: %w", err) + } + + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) + if err != nil { + return fmt.Errorf("could not resolve skill directory path: %w", err) + } + + for _, file := range files { + content, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA) + if err != nil { + return fmt.Errorf("could not fetch %s: %w", file.Path, err) + } + + relPath := strings.TrimPrefix(file.Path, skill.Path+"/") + + safeDest, err := safeSkillDir.Join(relPath) + if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return fmt.Errorf("blocked path traversal in %q", relPath) + } + return fmt.Errorf("could not resolve destination path: %w", err) + } + destPath := safeDest.String() + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + + if filepath.Base(relPath) == "SKILL.md" { + content, err = frontmatter.InjectGitHubMetadata(content, opts.Host, opts.Owner, opts.Repo, opts.Ref, skill.TreeSHA, opts.PinnedRef, skill.Path) + if err != nil { + return fmt.Errorf("could not inject metadata: %w", err) + } + } + + if err := os.WriteFile(destPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("could not write %s: %w", destPath, err) + } + } + + return nil +} + +// ResolveGitRoot returns the git repository root using the provided client, +// falling back to the current working directory on error. +func ResolveGitRoot(gc *git.Client) string { + if gc != nil && gc.RepoDir != "" { + return gc.RepoDir + } + if gc != nil { + if root, err := gc.ToplevelDir(context.Background()); err == nil { + return root + } + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" +} + +// ResolveHomeDir returns the user's home directory, or "" on error. +func ResolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go new file mode 100644 index 00000000000..e05a3541e9a --- /dev/null +++ b/internal/skills/installer/installer_test.go @@ -0,0 +1,518 @@ +package installer + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallLocal(t *testing.T) { + tests := []struct { + name string + skills []discovery.Skill + useAgentHost bool + setup func(t *testing.T, srcDir string) + verify func(t *testing.T, destDir string) + wantErr string + }{ + { + name: "copies files via Dir", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("review this PR"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "code-review", "prompt.txt")) + require.NoError(t, err) + assert.Equal(t, "review this PR", string(content)) + + _, err = os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "nested directories", + skills: []discovery.Skill{{Name: "issue-triage", Path: "skills/issue-triage"}}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + deep := filepath.Join(srcDir, "skills", "issue-triage", "prompts", "templates") + require.NoError(t, os.MkdirAll(deep, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(deep, "bug.txt"), []byte("triage bug"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(srcDir, "skills", "issue-triage", "SKILL.md"), []byte("# Issue Triage"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "issue-triage", "prompts", "templates", "bug.txt")) + require.NoError(t, err) + assert.Equal(t, "triage bug", string(content)) + }, + }, + { + name: "skips symlinks", + skills: []discovery.Skill{{Name: "pr-summary", Path: "skills/pr-summary"}}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "pr-summary") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# PR Summary"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("summarize"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt"))) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "pr-summary", "prompt.txt")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "pr-summary", "link.txt")) + assert.True(t, os.IsNotExist(err)) + }, + }, + { + name: "injects metadata into SKILL.md", + skills: []discovery.Skill{{Name: "copilot-helper", Path: "skills/copilot-helper"}}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "copilot-helper") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Copilot Helper\nAssists with tasks"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "copilot-helper", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "local-path") + }, + }, + { + name: "multiple skills", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review"}, + {Name: "issue-triage", Path: "skills/issue-triage"}, + }, + setup: func(t *testing.T, srcDir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillSrc := filepath.Join(srcDir, "skills", name) + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "issue-triage", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "resolves install dir from AgentHost and Scope", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, + useAgentHost: true, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, ".agents", "skills", "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "no dir or agent host", + skills: []discovery.Skill{{Name: "code-review"}}, + setup: func(t *testing.T, srcDir string) {}, + wantErr: "either Dir or AgentHost must be specified", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srcDir := t.TempDir() + destDir := t.TempDir() + tt.setup(t, srcDir) + + opts := &LocalOptions{ + SourceDir: srcDir, + Skills: tt.skills, + Dir: destDir, + } + if tt.useAgentHost { + host, err := registry.FindByID("github-copilot") + require.NoError(t, err) + opts.Dir = "" + opts.AgentHost = host + opts.Scope = registry.ScopeProject + opts.GitRoot = destDir + } + if tt.wantErr != "" { + opts.Dir = "" + } + + result, err := InstallLocal(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.NotEmpty(t, result.Dir) + assert.Len(t, result.Installed, len(tt.skills)) + tt.verify(t, destDir) + }) + } +} + +func TestInstallSkill(t *testing.T) { + tests := []struct { + name string + skill discovery.Skill + stubs func(*httpmock.Registry) + verify func(t *testing.T, destDir string) + }{ + { + name: "installs files from remote", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "skill-sha", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "prompt-sha", "size": 5}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/skill-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "skill-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Code Review")), + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/prompt-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "prompt-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("review this PR")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "code-review", "prompt.txt")) + require.NoError(t, err) + assert.Equal(t, "review this PR", string(content)) + + _, err = os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "injects metadata into SKILL.md", + skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary", TreeSHA: "tree456"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree456"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree456", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "md-sha", "size": 20}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/md-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "md-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# PR Summary\nSummarize pull requests")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "pr-summary", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "github-owner:") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") + }, + }, + { + name: "fails on path traversal from malicious tree", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "safe-sha", "size": 10}, + {"path": "../../etc/passwd", "type": "blob", "sha": "evil-sha", "size": 100}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/safe-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "safe-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Safe Skill")), + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/evil-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "evil-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("malicious content")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "..", "etc", "passwd")) + assert.True(t, os.IsNotExist(err), "traversal path should not be written") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + opts := &Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + } + + err := installSkill(opts, tt.skill, destDir) + if tt.name == "fails on path traversal from malicious tree" { + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked path traversal") + } else { + require.NoError(t, err) + } + tt.verify(t, destDir) + }) + } +} + +func stubTreeAndBlob(reg *httpmock.Registry, treeSHA string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/trees/%s", treeSHA)), + httpmock.JSONResponse(map[string]interface{}{ + "sha": treeSHA, "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": treeSHA + "-blob", "size": 10}, + }, + })) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s-blob", treeSHA)), + httpmock.JSONResponse(map[string]interface{}{ + "sha": treeSHA + "-blob", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Skill")), + })) +} + +func TestInstall(t *testing.T) { + var progressCount atomic.Int32 + + tests := []struct { + name string + skills []discovery.Skill + stubs func(*httpmock.Registry) + onProgress func(done, total int) + wantInstalled []string + wantErr string + }{ + { + name: "single skill calls OnProgress", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + }, + stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, + wantInstalled: []string{"code-review"}, + }, + { + name: "multiple skills concurrently with progress", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-it"}, + }, + stubs: func(reg *httpmock.Registry) { + stubTreeAndBlob(reg, "tree-cr") + stubTreeAndBlob(reg, "tree-it") + }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, + wantInstalled: []string{"code-review", "issue-triage"}, + }, + { + name: "partial failure returns successful installs and error", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-fail"}, + }, + stubs: func(reg *httpmock.Registry) { + stubTreeAndBlob(reg, "tree-cr") + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantInstalled: []string{"code-review"}, + wantErr: "failed to install skill", + }, + { + name: "no dir or agent host", + skills: []discovery.Skill{{Name: "code-review"}}, + stubs: func(reg *httpmock.Registry) {}, + wantErr: "either Dir or AgentHost must be specified", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + progressCount.Store(0) + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + opts := &Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: tt.skills, + Dir: destDir, + OnProgress: tt.onProgress, + } + if tt.wantErr != "" && len(tt.wantInstalled) == 0 { + opts.Dir = "" + } + + result, err := Install(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + if len(tt.wantInstalled) > 0 { + require.NotNil(t, result, "partial failure should return non-nil result") + assert.ElementsMatch(t, tt.wantInstalled, result.Installed) + } + return + } + require.NoError(t, err) + assert.ElementsMatch(t, tt.wantInstalled, result.Installed) + assert.Equal(t, destDir, result.Dir) + + homeDir, _ = os.UserHomeDir() + lockPath := filepath.Join(homeDir, ".agents", ".skill-lock.json") + lockData, err := os.ReadFile(lockPath) + require.NoError(t, err, "lockfile should have been written") + for _, name := range tt.wantInstalled { + assert.Contains(t, string(lockData), name) + } + if tt.onProgress != nil { + assert.True(t, progressCount.Load() > 0, "OnProgress should have been called") + } + }) + } +} + +func TestInstallSingleSkillFailureStillCompletesProgress(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), + httpmock.StatusStringResponse(500, "server error"), + ) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + var events []struct{ done, total int } + result, err := Install(&Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-fail"}, + }, + Dir: destDir, + OnProgress: func(done, total int) { + events = append(events, struct{ done, total int }{done: done, total: total}) + }, + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, []struct{ done, total int }{{done: 0, total: 1}, {done: 1, total: 1}}, events) +} + +func TestResolveGitRoot(t *testing.T) { + tests := []struct { + name string + client *git.Client + wantDir string + }{ + { + name: "returns RepoDir when set", + client: &git.Client{RepoDir: "/monalisa/repo"}, + wantDir: "/monalisa/repo", + }, + { + name: "nil client falls back to cwd", + client: nil, + }, + { + name: "empty RepoDir falls back to ToplevelDir or cwd", + client: &git.Client{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveGitRoot(tt.client) + if tt.wantDir != "" { + assert.Equal(t, tt.wantDir, got) + } else { + assert.NotEmpty(t, got, "should fall back to ToplevelDir or cwd") + } + }) + } +} diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go new file mode 100644 index 00000000000..2e6697234b4 --- /dev/null +++ b/internal/skills/lockfile/lockfile.go @@ -0,0 +1,178 @@ +package lockfile + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/cli/cli/v2/internal/flock" + "github.com/cli/cli/v2/internal/ghinstance" +) + +const ( + // lockVersion must match Vercel's CURRENT_LOCK_VERSION for interop. + lockVersion = 3 + agentsDir = ".agents" + lockFile = ".skill-lock.json" +) + +// entry represents a single installed skill in the lock file. +type entry struct { + Source string `json:"source"` + SourceType string `json:"sourceType"` + SourceURL string `json:"sourceUrl"` + SkillPath string `json:"skillPath,omitempty"` + SkillFolderHash string `json:"skillFolderHash"` + InstalledAt string `json:"installedAt"` + UpdatedAt string `json:"updatedAt"` + PinnedRef string `json:"pinnedRef,omitempty"` +} + +// file is the top-level structure of .skill-lock.json. +type file struct { + Version int `json:"version"` + Skills map[string]entry `json:"skills"` + Dismissed map[string]bool `json:"dismissed,omitempty"` +} + +// lockfilePath returns the absolute path to the lock file. +func lockfilePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, agentsDir, lockFile), nil +} + +// readFrom loads the lock file from an open file handle. +// Returns an empty file if the content is empty, corrupt, or incompatible. +func readFrom(f *os.File) (*file, error) { + if _, err := f.Seek(0, 0); err != nil { + return nil, fmt.Errorf("could not seek lock file: %w", err) + } + data, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("could not read lock file: %w", err) + } + if len(data) == 0 { + return newFile(), nil + } + + var lf file + if err := json.Unmarshal(data, &lf); err != nil { + return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state + } + + if lf.Version != lockVersion || lf.Skills == nil { + return newFile(), nil + } + + return &lf, nil +} + +// writeTo persists the lock file through an open file handle. +func writeTo(f *os.File, lf *file) error { + data, err := json.MarshalIndent(lf, "", " ") + if err != nil { + return err + } + + if _, err := f.Seek(0, 0); err != nil { + return err + } + if err := f.Truncate(0); err != nil { + return err + } + _, err = f.Write(data) + return err +} + +// RecordInstall adds or updates a skill entry in the lock file. +// It uses a file-based lock to prevent concurrent read-modify-write races +// when multiple install processes run simultaneously. +func RecordInstall(host, skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error { + lockPath, err := lockfilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return fmt.Errorf("could not create lock directory: %w", err) + } + + lockedFile, unlock, err := acquireFLock() + if err != nil { + return err + } + defer unlock() + + f, err := readFrom(lockedFile) + if err != nil { + return err + } + + now := time.Now().UTC().Format(time.RFC3339) + + existing, exists := f.Skills[skillName] + installedAt := now + if exists { + installedAt = existing.InstalledAt + } + + f.Skills[skillName] = entry{ + Source: owner + "/" + repo, + SourceType: "github", + SourceURL: ghinstance.HostPrefix(host) + owner + "/" + repo + ".git", + SkillPath: skillPath, + SkillFolderHash: treeSHA, + InstalledAt: installedAt, + UpdatedAt: now, + PinnedRef: pinnedRef, + } + + return writeTo(lockedFile, f) +} + +func newFile() *file { + return &file{ + Version: lockVersion, + Skills: make(map[string]entry), + } +} + +var ( + lockAttempts = 30 + lockAttemptDelay = 100 * time.Millisecond +) + +// acquireFLock attempts to acquire an exclusive file lock to serialize concurrent access. +// Returns the locked file handle and an unlock function, or an error if the lock +// cannot be acquired. The caller should read/write through the returned file to +// avoid Windows mandatory lock conflicts. +func acquireFLock() (f *os.File, unlock func(), err error) { + lockPath, err := lockfilePath() + if err != nil { + return nil, nil, fmt.Errorf("could not determine lock path: %w", err) + } + + var lastErr error + for attempt := range lockAttempts { + f, unlock, err := flock.TryLock(lockPath) + if err == nil { + return f, unlock, nil + } + lastErr = err + + if !errors.Is(err, flock.ErrLocked) { + return nil, nil, err + } + if attempt < lockAttempts-1 { + time.Sleep(lockAttemptDelay) + } + } + + return nil, nil, fmt.Errorf("could not acquire lock after %d attempts: %w", lockAttempts, lastErr) +} diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go new file mode 100644 index 00000000000..7a040a550fc --- /dev/null +++ b/internal/skills/lockfile/lockfile_test.go @@ -0,0 +1,226 @@ +package lockfile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupTestHome redirects HOME to a temp dir and returns the expected lockfile path. +func setupTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return filepath.Join(home, agentsDir, lockFile) +} + +func TestRecordInstall(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) + host string + skill string + owner string + repo string + skillPath string + treeSHA string + pinnedRef string + wantErr bool + verify func(t *testing.T, lockPath string) + }{ + { + name: "fresh install creates lockfile", + host: "github.com", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + e := f.Skills["code-review"] + assert.Equal(t, "monalisa/octocat-skills", e.Source) + assert.Equal(t, "github", e.SourceType) + assert.Equal(t, "https://github.com/monalisa/octocat-skills.git", e.SourceURL) + assert.Equal(t, "skills/code-review/SKILL.md", e.SkillPath) + assert.Equal(t, "abc123", e.SkillFolderHash) + assert.NotEmpty(t, e.InstalledAt) + assert.NotEmpty(t, e.UpdatedAt) + assert.Empty(t, e.PinnedRef) + }, + }, + { + name: "tenancy host uses correct URL", + host: "mycompany.ghe.com", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + e := f.Skills["code-review"] + assert.Equal(t, "https://mycompany.ghe.com/monalisa/octocat-skills.git", e.SourceURL) + }, + }, + { + name: "install with pinned ref", + host: "github.com", + skill: "pr-summary", + owner: "hubot", + repo: "skills-repo", + skillPath: "skills/pr-summary/SKILL.md", + treeSHA: "def456", + pinnedRef: "v1.0.0", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, "v1.0.0", f.Skills["pr-summary"].PinnedRef) + }, + }, + { + name: "multiple skills coexist", + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, RecordInstall("github.com", "code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "sha1", "")) + }, + host: "github.com", + skill: "issue-triage", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/issue-triage/SKILL.md", + treeSHA: "sha2", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Contains(t, f.Skills, "code-review") + assert.Contains(t, f.Skills, "issue-triage") + }, + }, + { + name: "returns error when lock cannot be acquired", + setup: func(t *testing.T) { + t.Helper() + origAttempts := lockAttempts + origDelay := lockAttemptDelay + lockAttempts = 1 + lockAttemptDelay = 0 + t.Cleanup(func() { + lockAttempts = origAttempts + lockAttemptDelay = origDelay + }) + // Hold a real flock so acquireFLock fails. + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + t.Cleanup(unlock) + }, + host: "github.com", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + wantErr: true, + }, + { + name: "recovers from corrupt lockfile", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) + }, + host: "github.com", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") + }, + }, + { + name: "recovers from wrong version lockfile", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"old-skill": {}}}) + require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + }, + host: "github.com", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") + assert.NotContains(t, f.Skills, "old-skill", "wrong-version data should be discarded") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := setupTestHome(t) + if tt.setup != nil { + tt.setup(t) + } + + err := RecordInstall(tt.host, tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + tt.verify(t, lockPath) + }) + } + + // This case lives outside the table because it needs to read the lockfile + // between two RecordInstall calls to capture the first InstalledAt value. + t.Run("update preserves InstalledAt and updates treeSHA", func(t *testing.T) { + lockPath := setupTestHome(t) + + require.NoError(t, RecordInstall("github.com", "code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + firstInstalledAt := readTestLockfile(t, lockPath).Skills["code-review"].InstalledAt + + require.NoError(t, RecordInstall("github.com", "code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "new-sha", "")) + entry := readTestLockfile(t, lockPath).Skills["code-review"] + + assert.Equal(t, "new-sha", entry.SkillFolderHash, "treeSHA should be updated") + assert.Equal(t, firstInstalledAt, entry.InstalledAt, "InstalledAt should be preserved from first install") + }) +} + +// readTestLockfile is a test helper that reads and parses the lockfile from disk. +func readTestLockfile(t *testing.T, path string) *file { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err, "lockfile should exist at %s", path) + var f file + require.NoError(t, json.Unmarshal(data, &f)) + return &f +} diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go new file mode 100644 index 00000000000..a5e018176cf --- /dev/null +++ b/internal/skills/registry/registry.go @@ -0,0 +1,427 @@ +package registry + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" +) + +// AgentHost represents an AI agent that can use skills. +type AgentHost struct { + // ID is the canonical identifier for this agent host. + ID string + // Name is the human-readable display name. + Name string + // ProjectDir is the relative path within a project for skills. + ProjectDir string + // UserDir is the relative path within the user's home directory for skills. + UserDir string +} + +// Scope determines where skills are installed. +type Scope string + +const ( + ScopeProject Scope = "project" + ScopeUser Scope = "user" + + DefaultAgentID = "github-copilot" + + sharedProjectSkillsDir = ".agents/skills" +) + +// Agents contains all known agent hosts. +// +// The slice is ordered so that the most widely used agents appear first, +// followed by the rest in alphabetical order. This order is used for +// interactive selection, help output, and flag enum suggestions. +// +// Agents sharing a ProjectDir (such as the shared .agents/skills directory) +// install skills to the same project-scope location, so selecting multiple +// such agents writes each skill only once. +var Agents = []AgentHost{ + // Popular agents, listed first for discoverability. + { + ID: "github-copilot", + Name: "GitHub Copilot", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".copilot/skills", + }, + { + ID: "claude-code", + Name: "Claude Code", + ProjectDir: ".claude/skills", + UserDir: ".claude/skills", + }, + { + ID: "cursor", + Name: "Cursor", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".cursor/skills", + }, + { + ID: "codex", + Name: "Codex", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".codex/skills", + }, + { + ID: "gemini-cli", + Name: "Gemini CLI", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".gemini/skills", + }, + { + ID: "antigravity", + Name: "Antigravity", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".gemini/antigravity/skills", + }, + + // All other supported agents, alphabetical by ID. + { + ID: "adal", + Name: "AdaL", + ProjectDir: ".adal/skills", + UserDir: ".adal/skills", + }, + { + ID: "amp", + Name: "Amp", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "augment", + Name: "Augment", + ProjectDir: ".augment/skills", + UserDir: ".augment/skills", + }, + { + ID: "bob", + Name: "IBM Bob", + ProjectDir: ".bob/skills", + UserDir: ".bob/skills", + }, + { + ID: "cline", + Name: "Cline", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".agents/skills", + }, + { + ID: "codebuddy", + Name: "CodeBuddy", + ProjectDir: ".codebuddy/skills", + UserDir: ".codebuddy/skills", + }, + { + ID: "command-code", + Name: "Command Code", + ProjectDir: ".commandcode/skills", + UserDir: ".commandcode/skills", + }, + { + ID: "continue", + Name: "Continue", + ProjectDir: ".continue/skills", + UserDir: ".continue/skills", + }, + { + ID: "cortex", + Name: "Cortex Code", + ProjectDir: ".cortex/skills", + UserDir: ".snowflake/cortex/skills", + }, + { + ID: "crush", + Name: "Crush", + ProjectDir: ".crush/skills", + UserDir: ".config/crush/skills", + }, + { + ID: "deepagents", + Name: "Deep Agents", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".deepagents/agent/skills", + }, + { + ID: "droid", + Name: "Droid", + ProjectDir: ".factory/skills", + UserDir: ".factory/skills", + }, + { + ID: "firebender", + Name: "Firebender", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".firebender/skills", + }, + { + ID: "goose", + Name: "Goose", + ProjectDir: ".goose/skills", + UserDir: ".config/goose/skills", + }, + { + ID: "iflow-cli", + Name: "iFlow CLI", + ProjectDir: ".iflow/skills", + UserDir: ".iflow/skills", + }, + { + ID: "junie", + Name: "Junie", + ProjectDir: ".junie/skills", + UserDir: ".junie/skills", + }, + { + ID: "kilo", + Name: "Kilo Code", + ProjectDir: ".kilocode/skills", + UserDir: ".kilocode/skills", + }, + { + ID: "kimi-cli", + Name: "Kimi Code CLI", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "kiro-cli", + Name: "Kiro CLI", + ProjectDir: ".kiro/skills", + UserDir: ".kiro/skills", + }, + { + ID: "kode", + Name: "Kode", + ProjectDir: ".kode/skills", + UserDir: ".kode/skills", + }, + { + ID: "mcpjam", + Name: "MCPJam", + ProjectDir: ".mcpjam/skills", + UserDir: ".mcpjam/skills", + }, + { + ID: "mistral-vibe", + Name: "Mistral Vibe", + ProjectDir: ".vibe/skills", + UserDir: ".vibe/skills", + }, + { + ID: "mux", + Name: "Mux", + ProjectDir: ".mux/skills", + UserDir: ".mux/skills", + }, + { + ID: "neovate", + Name: "Neovate", + ProjectDir: ".neovate/skills", + UserDir: ".neovate/skills", + }, + { + ID: "openclaw", + Name: "OpenClaw", + ProjectDir: "skills", + UserDir: ".openclaw/skills", + }, + { + ID: "opencode", + Name: "OpenCode", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/opencode/skills", + }, + { + ID: "openhands", + Name: "OpenHands", + ProjectDir: ".openhands/skills", + UserDir: ".openhands/skills", + }, + { + ID: "pi", + Name: "Pi", + ProjectDir: ".pi/skills", + UserDir: ".pi/agent/skills", + }, + { + ID: "pochi", + Name: "Pochi", + ProjectDir: ".pochi/skills", + UserDir: ".pochi/skills", + }, + { + ID: "qoder", + Name: "Qoder", + ProjectDir: ".qoder/skills", + UserDir: ".qoder/skills", + }, + { + ID: "qwen-code", + Name: "Qwen Code", + ProjectDir: ".qwen/skills", + UserDir: ".qwen/skills", + }, + { + ID: "replit", + Name: "Replit", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "roo", + Name: "Roo Code", + ProjectDir: ".roo/skills", + UserDir: ".roo/skills", + }, + { + ID: "trae", + Name: "Trae", + ProjectDir: ".trae/skills", + UserDir: ".trae/skills", + }, + { + ID: "trae-cn", + Name: "Trae CN", + ProjectDir: ".trae/skills", + UserDir: ".trae-cn/skills", + }, + { + ID: "universal", + Name: "Universal", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "warp", + Name: "Warp", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".agents/skills", + }, + { + ID: "windsurf", + Name: "Windsurf", + ProjectDir: ".windsurf/skills", + UserDir: ".codeium/windsurf/skills", + }, + { + ID: "zencoder", + Name: "Zencoder", + ProjectDir: ".zencoder/skills", + UserDir: ".zencoder/skills", + }, +} + +// FindByID returns the agent host with the given ID, or an error if not found. +func FindByID(id string) (*AgentHost, error) { + for i := range Agents { + if Agents[i].ID == id { + return &Agents[i], nil + } + } + return nil, fmt.Errorf("unknown agent %q, valid agents: %s", id, ValidAgentIDs()) +} + +// ValidAgentIDs returns a comma-separated list of valid agent IDs. +func ValidAgentIDs() string { + return strings.Join(AgentIDs(), ", ") +} + +// AgentIDs returns the IDs of all known agents as a slice. +func AgentIDs() []string { + ids := make([]string, len(Agents)) + for i, h := range Agents { + ids[i] = h.ID + } + return ids +} + +// AgentHelpList returns a newline-separated bulleted list of agents for help text. +func AgentHelpList() string { + lines := make([]string, len(Agents)) + for i, h := range Agents { + lines[i] = fmt.Sprintf(" - %s (%s)", h.Name, h.ID) + } + return strings.Join(lines, "\n") +} + +// AgentNames returns the display names of all agents for prompting. +func AgentNames() []string { + names := make([]string, len(Agents)) + for i, h := range Agents { + names[i] = h.Name + } + return names +} + +// UniqueProjectDirs returns the deduplicated set of project-scope skill +// directories from the Agents list, preserving insertion order. +func UniqueProjectDirs() []string { + seen := map[string]bool{} + var dirs []string + for _, h := range Agents { + if !seen[h.ProjectDir] { + seen[h.ProjectDir] = true + dirs = append(dirs, h.ProjectDir) + } + } + return dirs +} + +// InstallDir resolves the absolute installation directory for an agent host and scope. +// For project scope, it uses the provided git root directory so that skills are +// installed at the top level regardless of which subdirectory the user is in. +// Returns an error when gitRoot is empty (not in a git repository). +// For user scope, it uses the home directory. +func (h *AgentHost) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) { + switch scope { + case ScopeProject: + if gitRoot == "" { + return "", fmt.Errorf("could not determine project root directory") + } + return filepath.Join(gitRoot, h.ProjectDir), nil + case ScopeUser: + if homeDir == "" { + return "", fmt.Errorf("could not determine home directory") + } + return filepath.Join(homeDir, h.UserDir), nil + default: + return "", fmt.Errorf("invalid scope %q", scope) + } +} + +// ScopeLabels returns the display labels for the scope selection prompt. +// If repoName is non-empty, it is included in the project-scope label +// for additional context. +func ScopeLabels(repoName string) []string { + projectLabel := "Project: install in current repository (recommended)" + if repoName != "" { + projectLabel = fmt.Sprintf("Project: %s (recommended)", repoName) + } + return []string{ + projectLabel, + "Global: install in home directory (available everywhere)", + } +} + +// RepoNameFromRemote extracts "owner/repo" from a git remote URL. +func RepoNameFromRemote(remote string) string { + if remote == "" { + return "" + } + u, err := git.ParseURL(remote) + if err != nil { + return "" + } + repo, err := ghrepo.FromURL(u) + if err != nil { + return "" + } + return ghrepo.FullName(repo) +} diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go new file mode 100644 index 00000000000..bd0c4470963 --- /dev/null +++ b/internal/skills/registry/registry_test.go @@ -0,0 +1,216 @@ +package registry + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindByID(t *testing.T) { + tests := []struct { + name string + id string + wantName string + wantErr string + }{ + {name: "github-copilot", id: "github-copilot", wantName: "GitHub Copilot"}, + {name: "claude-code", id: "claude-code", wantName: "Claude Code"}, + {name: "cursor", id: "cursor", wantName: "Cursor"}, + {name: "codex", id: "codex", wantName: "Codex"}, + {name: "gemini-cli", id: "gemini-cli", wantName: "Gemini CLI"}, + {name: "antigravity", id: "antigravity", wantName: "Antigravity"}, + {name: "unknown agent", id: "nonexistent", wantErr: "unknown agent"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, err := FindByID(tt.id) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, host.Name) + }) + } +} + +func TestInstallDir(t *testing.T) { + tests := []struct { + name string + hostID string + scope Scope + gitRoot string + homeDir string + wantDir string + wantErr bool + }{ + { + name: "github copilot project scope", + hostID: "github-copilot", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "github copilot user scope", + hostID: "github-copilot", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/home/monalisa", ".copilot", "skills"), + }, + { + name: "claude code project scope", + hostID: "claude-code", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".claude", "skills"), + }, + { + name: "cursor project scope", + hostID: "cursor", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "codex project scope", + hostID: "codex", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "gemini project scope", + hostID: "gemini-cli", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "antigravity project scope", + hostID: "antigravity", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "project scope without git root", + hostID: "github-copilot", + scope: ScopeProject, + gitRoot: "", + homeDir: "/home/monalisa", + wantErr: true, + }, + { + name: "user scope without home dir", + hostID: "github-copilot", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "", + wantErr: true, + }, + { + name: "invalid scope", + hostID: "github-copilot", + scope: "bogus", + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, err := FindByID(tt.hostID) + require.NoError(t, err) + + dir, err := host.InstallDir(tt.scope, tt.gitRoot, tt.homeDir) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantDir, dir) + }) + } +} + +func TestRepoNameFromRemote(t *testing.T) { + tests := []struct { + remote string + want string + }{ + {"https://github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"https://github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"git@github.com:monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"git@github.com:monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"ssh://git@github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"ssh://git@github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"not-a-url", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.remote, func(t *testing.T) { + assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote)) + }) + } +} + +func TestUniqueProjectDirs(t *testing.T) { + dirs := UniqueProjectDirs() + seen := map[string]int{} + for _, d := range dirs { + seen[d]++ + } + // The shared .agents/skills dir and .claude/skills must both be present + // and listed exactly once each. + assert.Equal(t, 1, seen[".agents/skills"], "expected .agents/skills exactly once") + assert.Equal(t, 1, seen[".claude/skills"], "expected .claude/skills exactly once") + // No project dir should appear more than once. + for d, n := range seen { + assert.LessOrEqualf(t, n, 1, "project dir %q appears %d times", d, n) + } +} + +func TestScopeLabels(t *testing.T) { + tests := []struct { + name string + repoName string + wantFirst []string + wantSecond []string + }{ + { + name: "without repo name", + repoName: "", + wantFirst: []string{"Project", "recommended"}, + wantSecond: []string{"Global"}, + }, + { + name: "with repo name", + repoName: "monalisa/octocat-skills", + wantFirst: []string{"monalisa/octocat-skills", "recommended"}, + wantSecond: []string{"Global"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := ScopeLabels(tt.repoName) + require.Len(t, labels, 2) + for _, s := range tt.wantFirst { + assert.Contains(t, labels[0], s) + } + for _, s := range tt.wantSecond { + assert.Contains(t, labels[1], s) + } + }) + } +} diff --git a/internal/skills/source/source.go b/internal/skills/source/source.go new file mode 100644 index 00000000000..ff0e5e9d76e --- /dev/null +++ b/internal/skills/source/source.go @@ -0,0 +1,73 @@ +package source + +import ( + "fmt" + "strings" + + ghauth "github.com/cli/go-gh/v2/pkg/auth" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +const SupportedHost = "github.com" + +// BuildRepoURL returns the canonical repository URL stored in skill metadata. +func BuildRepoURL(host, owner, repo string) string { + return ghrepo.GenerateRepoURL(ghrepo.NewWithHost(owner, repo, host), "") +} + +// ParseRepoURL parses a repository URL stored in skill metadata. +func ParseRepoURL(raw string) (ghrepo.Interface, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("repository URL is empty") + } + + repo, err := ghrepo.FromFullName(raw) + if err != nil { + return nil, fmt.Errorf("invalid repository URL %q: %w", raw, err) + } + + return repo, nil +} + +// ParseMetadataRepo extracts repository information from skill metadata. +func ParseMetadataRepo(meta map[string]interface{}) (ghrepo.Interface, bool, error) { + if meta == nil { + return nil, false, nil + } + + repoValue, _ := meta["github-repo"].(string) + if repoValue == "" { + return nil, false, nil + } + + repo, err := ParseRepoURL(repoValue) + if err != nil { + return nil, true, err + } + + return repo, true, nil +} + +// ValidateSupportedHost rejects hosts that are not supported. +// Supported hosts are github.com and GHEC with data residency (*.ghe.com). +// GitHub Enterprise Server is not currently supported. +func ValidateSupportedHost(host string) error { + host = normalizeHost(host) + if host == "" { + return fmt.Errorf("could not determine repository host") + } + if host == SupportedHost || ghauth.IsTenancy(host) { + return nil + } + if ghauth.IsEnterprise(host) { + return fmt.Errorf("GitHub Skills does not currently support GitHub Enterprise Server; got %s", host) + } + return fmt.Errorf("unsupported host for GitHub Skills: %s", host) +} + +func normalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + return strings.TrimPrefix(host, "www.") +} diff --git a/internal/skills/source/source_test.go b/internal/skills/source/source_test.go new file mode 100644 index 00000000000..9c2457d3f7a --- /dev/null +++ b/internal/skills/source/source_test.go @@ -0,0 +1,78 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildRepoURL(t *testing.T) { + assert.Equal(t, "https://github.com/monalisa/octocat-skills", BuildRepoURL("github.com", "monalisa", "octocat-skills")) +} + +func TestParseMetadataRepo(t *testing.T) { + tests := []struct { + name string + meta map[string]interface{} + wantOwner string + wantRepo string + wantHost string + wantFound bool + wantErr string + }{ + { + name: "parses repo url metadata", + meta: map[string]interface{}{ + "github-repo": "https://github.com/monalisa/octocat-skills", + }, + wantOwner: "monalisa", + wantRepo: "octocat-skills", + wantHost: SupportedHost, + wantFound: true, + }, + { + name: "invalid repo url", + meta: map[string]interface{}{ + "github-repo": "not a url", + }, + wantFound: true, + wantErr: "invalid repository URL", + }, + { + name: "missing repo metadata", + meta: map[string]interface{}{}, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, found, err := ParseMetadataRepo(tt.meta) + assert.Equal(t, tt.wantFound, found) + if !tt.wantFound { + require.NoError(t, err) + assert.Nil(t, repo) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, tt.wantOwner, repo.RepoOwner()) + assert.Equal(t, tt.wantRepo, repo.RepoName()) + assert.Equal(t, tt.wantHost, repo.RepoHost()) + }) + } +} + +func TestValidateSupportedHost(t *testing.T) { + require.NoError(t, ValidateSupportedHost("github.com")) + require.NoError(t, ValidateSupportedHost("mycompany.ghe.com"), "GHEC data residency tenancy hosts should be accepted") + require.ErrorContains(t, ValidateSupportedHost("acme.ghes.com"), "does not currently support GitHub Enterprise Server") + require.ErrorContains(t, ValidateSupportedHost("github.localhost"), "unsupported host") +} diff --git a/internal/telemetry/detach_unix.go b/internal/telemetry/detach_unix.go new file mode 100644 index 00000000000..f2f6011bcd9 --- /dev/null +++ b/internal/telemetry/detach_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package telemetry + +import "syscall" + +// detachAttrs returns SysProcAttr configured to place the child in its own +// process group so that terminal signals delivered to the parent's group +// (SIGINT, SIGHUP) are not forwarded to the child. +func detachAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setpgid: true} +} diff --git a/internal/telemetry/detach_windows.go b/internal/telemetry/detach_windows.go new file mode 100644 index 00000000000..c4d62b30770 --- /dev/null +++ b/internal/telemetry/detach_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package telemetry + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +// detachAttrs returns SysProcAttr configured to place the child in its own +// process group so that console signals (Ctrl+C) delivered to the parent's +// group are not forwarded to the child, and to suppress any console window +// for the child and its descendants. +// +// CREATE_NO_WINDOW is preferred over DETACHED_PROCESS here: DETACHED_PROCESS +// removes the console entirely, which causes any console-subsystem descendant +// (e.g. tzutil.exe invoked transitively to resolve the local IANA timezone) +// to allocate a fresh conhost window, producing a visible flash on every gh +// invocation. CREATE_NO_WINDOW gives the child a non-visible console that +// descendants can inherit, avoiding the flash. +func detachAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW} +} diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go new file mode 100644 index 00000000000..4eb22e898a5 --- /dev/null +++ b/internal/telemetry/fake.go @@ -0,0 +1,35 @@ +package telemetry + +import "github.com/cli/cli/v2/internal/gh/ghtelemetry" + +type EventRecorderSpy struct { + Events []ghtelemetry.Event +} + +func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { + r.Events = append(r.Events, event) +} + +func (r *EventRecorderSpy) Disable() {} + +func (r *EventRecorderSpy) Flush() {} + +// CommandRecorderSpy is a test double for ghtelemetry.CommandRecorder. +// It captures recorded events and the most recent SetSampleRate call so tests can +// assert on the sampling behavior commands attempt to configure. +type CommandRecorderSpy struct { + Events []ghtelemetry.Event + LastSampleRate int +} + +func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) { + r.Events = append(r.Events, event) +} + +func (r *CommandRecorderSpy) Disable() {} + +func (r *CommandRecorderSpy) SetSampleRate(rate int) { + r.LastSampleRate = rate +} + +func (r *CommandRecorderSpy) Flush() {} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000000..3943060b124 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,425 @@ +// Package telemetry provides best-effort usage telemetry for gh commands. +package telemetry + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/pkg/jsoncolor" + "github.com/google/uuid" + "github.com/mgutz/ansi" +) + +const deviceIDFileName = "device-id" + +// stateDirFunc returns the state directory path. Can be replaced in tests. +var stateDirFunc = config.StateDir + +// deviceIDFunc returns a per-user device identifier stored in the state directory. +// It generates and persists a UUID on first call. Can be replaced in tests. +var deviceIDFunc = getOrCreateDeviceID + +func getOrCreateDeviceID() (string, error) { + stateDir := stateDirFunc() + idPath := filepath.Join(stateDir, deviceIDFileName) + + data, err := os.ReadFile(idPath) + if err == nil { + return strings.TrimSpace(string(data)), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + id := uuid.New().String() + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return "", err + } + + // Write the ID to a temp file in the same directory, then hard-link it + // to the target path. os.Link fails atomically if the target already + // exists, so exactly one concurrent caller wins. Losers read the + // winner's ID. The temp file is always cleaned up. + tmpFile, err := os.CreateTemp(stateDir, deviceIDFileName+".tmp.*") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(id); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return "", err + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return "", err + } + + linkErr := os.Link(tmpPath, idPath) + os.Remove(tmpPath) + + if linkErr != nil { + // Another caller won — read their ID. + data, readErr := os.ReadFile(idPath) + if readErr != nil { + return "", linkErr + } + return strings.TrimSpace(string(data)), nil + } + + return id, nil +} + +var falseyValues = []string{"", "0", "false", "no", "disabled", "off"} + +// lookupEnvFunc wraps os.LookupEnv. Can be replaced in tests. +var lookupEnvFunc = os.LookupEnv + +type TelemetryState string + +const ( + Enabled TelemetryState = "enabled" + Disabled TelemetryState = "disabled" + Logged TelemetryState = "log" +) + +// ParseTelemetryState determines the telemetry state based on environment variables and configuration values. +// The GH_TELEMETRY environment variable takes precedence, followed by DO_NOT_TRACK, then the configuration value. +// Recognized values for GH_TELEMETRY and config are "enabled", "disabled", "log", or any falsey value (e.g. "0", "false", "no") to disable telemetry. +func ParseTelemetryState(configValue string) TelemetryState { + // GH_TELEMETRY env var takes highest precedence + if envVal, ok := lookupEnvFunc("GH_TELEMETRY"); ok { + envVal = strings.TrimSpace(strings.ToLower(envVal)) + + // If falsey, telemetry is disabled. + if slices.Contains(falseyValues, envVal) { + return Disabled + } + + // If logged, telemetry is logged instead of sent. + if envVal == "log" { + return Logged + } + + // Any other value (including "enabled") is treated as enabled. + return Enabled + } + + // DO_NOT_TRACK takes precedence over config + if envVal, ok := lookupEnvFunc("DO_NOT_TRACK"); ok { + envVal = strings.TrimSpace(strings.ToLower(envVal)) + if envVal == "1" || envVal == "true" { + return Disabled + } + } + + // Then check the config values with the same rules. + configValue = strings.TrimSpace(strings.ToLower(configValue)) + + if slices.Contains(falseyValues, configValue) { + return Disabled + } + + if configValue == "log" { + return Logged + } + + return Enabled +} + +type telemetryServiceOpts struct { + additionalDimensions ghtelemetry.Dimensions + sampleRate int +} + +type telemetryServiceOption func(*telemetryServiceOpts) + +// WithAdditionalCommonDimensions allows setting additional common dimensions that will be included with every telemetry event recorded by the service. +func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) telemetryServiceOption { + return func(s *telemetryServiceOpts) { + maps.Copy(s.additionalDimensions, dimensions) + } +} + +// WithSampleRate allows setting a sample rate (0-100) for telemetry events. Events recorded with the Unsampled option will be sent regardless of the sample rate. +// Sampling is based on invocation ID, so an entire invocation will be included or excluded as a whole. This ensures that related events are not split between sampled and unsampled, +// which could lead to incomplete data and incorrect assumptions. +func WithSampleRate(rate int) telemetryServiceOption { + return func(s *telemetryServiceOpts) { + s.sampleRate = rate + } +} + +// LogFlusher returns a flush function that writes telemetry payloads to the provided log writer. This is used for the "log" telemetry mode, which is intended for debugging and development. +// When there are no events to report (for example the command opted out of telemetry, the user is on GHES, or no events were recorded), a "Telemetry payload: none" marker is written so that the absence of events is observable. +var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) { + return func(payload SendTelemetryPayload) { + header := "Telemetry payload:" + if colorEnabled { + header = ansi.Color(header, "cyan+b") + } + + if len(payload.Events) == 0 { + fmt.Fprintf(log, "%s none\n", header) + return + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return + } + + fmt.Fprintf(log, "%s\n", header) + + if colorEnabled { + _ = jsoncolor.Write(log, bytes.NewReader(payloadBytes), " ") + } else { + var indented bytes.Buffer + _ = json.Indent(&indented, payloadBytes, "", " ") + fmt.Fprintln(log, indented.String()) + } + } +} + +// GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode. +// Empty payloads are dropped without spawning a subprocess. +var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { + return func(payload SendTelemetryPayload) { + if len(payload.Events) == 0 { + return + } + SpawnSendTelemetry(executable, payload) + } +} + +// NewService creates a new telemetry service with the provided flush function and options. +func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOption) ghtelemetry.Service { + telemetryServiceOpts := telemetryServiceOpts{ + additionalDimensions: make(ghtelemetry.Dimensions), + } + for _, opt := range opts { + opt(&telemetryServiceOpts) + } + + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + + invocationID := uuid.NewString() + + var commonDimensions = ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": invocationID, + "os": runtime.GOOS, + "architecture": runtime.GOARCH, + } + maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions) + + hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) + sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) + + s := &service{ + flush: flusher, + commonDimensions: commonDimensions, + sampleRate: telemetryServiceOpts.sampleRate, + sampleBucket: sampleBucket, + } + + return s +} + +type recordedEvent struct { + event ghtelemetry.Event + recordedAt time.Time +} + +type service struct { + mu sync.RWMutex + flush func(payload SendTelemetryPayload) + previouslyCalled bool + + commonDimensions ghtelemetry.Dimensions + sampleRate int + sampleBucket byte + + events []recordedEvent + + disabled bool +} + +func (s *service) Disable() { + s.mu.Lock() + defer s.mu.Unlock() + + s.disabled = true +} + +func (s *service) Record(event ghtelemetry.Event) { + s.mu.Lock() + defer s.mu.Unlock() + + s.events = append(s.events, recordedEvent{event: event, recordedAt: time.Now()}) +} + +func (s *service) SetSampleRate(rate int) { + s.mu.Lock() + defer s.mu.Unlock() + + s.sampleRate = rate + s.commonDimensions["sample_rate"] = strconv.Itoa(rate) +} + +func (s *service) Flush() { + // This shouldn't really be required since flush should only be called once, but just in case... + s.mu.Lock() + defer s.mu.Unlock() + + if s.previouslyCalled { + return + } + s.previouslyCalled = true + + if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { + return + } + + // When the service has been disabled mid-invocation (e.g. an enterprise host + // was contacted), discard any recorded events. We still call the flusher + // with an empty payload so that the log-mode flusher can surface the + // absence of telemetry rather than leaving the user staring at silence. + events := s.events + if s.disabled { + events = nil + } + + payload := SendTelemetryPayload{ + Events: make([]PayloadEvent, len(events)), + } + + for i, recorded := range events { + dimensions := map[string]string{ + "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + } + maps.Copy(dimensions, s.commonDimensions) + maps.Copy(dimensions, recorded.event.Dimensions) + + payload.Events[i] = PayloadEvent{ + Type: recorded.event.Type, + Dimensions: dimensions, + Measures: recorded.event.Measures, + } + } + + s.flush(payload) +} + +// maxPayloadSize is a safety limit for the telemetry payload written to the +// child process stdin pipe. This bounds the data transferred to a reasonable +// size and avoids blocking on pipe buffer capacity (typically 16-64 KB). +const maxPayloadSize = 16 * 1024 + +// PayloadEvent represents a single telemetry event in the wire format. +type PayloadEvent struct { + Type string `json:"type"` + Dimensions map[string]string `json:"dimensions,omitempty"` + Measures map[string]int64 `json:"measures,omitempty"` +} + +type SendTelemetryPayload struct { + Events []PayloadEvent `json:"events"` +} + +// SpawnSendTelemetry spawns a detached subprocess to send telemetry. +// The payload is written to the child's stdin via a pipe so that it is not +// visible to other users through process argument inspection (e.g. ps aux). +// The parent writes the full payload and closes the pipe before returning, +// so no long-lived pipe is needed and the parent can exit immediately. +// +// Note: the payload is bounded by maxPayloadSize (16 KB). On macOS the +// default pipe buffer is also 16 KB, so in theory a write could block +// briefly if the child hasn't started reading yet. In practice the child +// is already running after cmd.Start(), so this is unlikely. +// +// All errors are silently ignored since telemetry is best-effort. +func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return + } + + if len(payloadBytes) > maxPayloadSize { + return + } + + // Resolve the executable to an absolute path before changing the child's + // working directory. Without this, a relative path (e.g. from GH_PATH) would + // be resolved against cmd.Dir at Start time and fail to spawn. + if abs, err := filepath.Abs(executable); err == nil { + executable = abs + } + + cmd := exec.Command(executable, "send-telemetry") + + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + // Set the working directory to a stable directory elsewhere so that the subprocess doesn't + // hold a reference to the parent's current working directory, avoiding any weirdness around + // deleting the parent process's current working directory while the child is still running. + // Only do this when we have an absolute executable path so that the child can still be found. + if filepath.IsAbs(executable) { + cmd.Dir = os.TempDir() + } + + // Configure the child process to be detached from the parent so that it can continue running + // after the parent exits, and so that it doesn't receive any signals sent to the parent. + cmd.SysProcAttr = detachAttrs() + + // Get the write end of the stdin pipe before starting. + stdin, err := cmd.StdinPipe() + if err != nil { + return + } + + if err := cmd.Start(); err != nil { + _ = stdin.Close() + return + } + + // Write the payload synchronously into the kernel pipe buffer, then close + // the pipe to signal EOF. The child reads the complete payload from stdin. + // io.Copy loops until all bytes are written, avoiding any risk of a short write. + _, _ = io.Copy(stdin, bytes.NewReader(payloadBytes)) + _ = stdin.Close() + + // Release resources associated with the child process since we will never Wait for it. + _ = cmd.Process.Release() +} + +type NoOpService struct{} + +func (s *NoOpService) Record(event ghtelemetry.Event) {} + +func (s *NoOpService) Disable() {} + +func (s *NoOpService) SetSampleRate(rate int) {} + +func (s *NoOpService) Flush() {} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000000..98180a1263c --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,723 @@ +package telemetry + +import ( + "bytes" + "errors" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stubStateDir(dir string) func() { + orig := stateDirFunc + stateDirFunc = func() string { return dir } + return func() { stateDirFunc = orig } +} + +func stubDeviceID(id string) func() { + orig := deviceIDFunc + deviceIDFunc = func() (string, error) { return id, nil } + return func() { deviceIDFunc = orig } +} + +func stubDeviceIDError(err error) func() { + orig := deviceIDFunc + deviceIDFunc = func() (string, error) { return "", err } + return func() { deviceIDFunc = orig } +} + +func stubLookupEnv(fn func(string) (string, bool)) func() { + orig := lookupEnvFunc + lookupEnvFunc = fn + return func() { lookupEnvFunc = orig } +} + +// newService is a test helper that constructs the internal service struct +// directly, bypassing the config/env parsing of NewService but still +// resolving common dimensions like device_id and invocation_id. +func newService(flusher func(SendTelemetryPayload), additionalDimensions ghtelemetry.Dimensions) *service { + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + + commonDimensions := ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": uuid.NewString(), + } + maps.Copy(commonDimensions, additionalDimensions) + + return &service{ + flush: flusher, + commonDimensions: commonDimensions, + } +} + +func TestGetOrCreateDeviceID(t *testing.T) { + t.Run("creates new ID on first call", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + require.NotEmpty(t, id) + + data, err := os.ReadFile(filepath.Join(tmpDir, deviceIDFileName)) + require.NoError(t, err) + assert.Equal(t, id, string(data)) + }) + + t.Run("returns same ID on subsequent calls", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + id1, err := getOrCreateDeviceID() + require.NoError(t, err) + + id2, err := getOrCreateDeviceID() + require.NoError(t, err) + + assert.Equal(t, id1, id2) + }) + + t.Run("trims whitespace from stored ID", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + err := os.WriteFile(filepath.Join(tmpDir, deviceIDFileName), []byte(" some-device-id\n"), 0o600) + require.NoError(t, err) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + assert.Equal(t, "some-device-id", id) + }) + + t.Run("returns error for non-ErrNotExist read failures", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + // Create device-id as a directory so ReadFile fails with a non-ErrNotExist error. + err := os.Mkdir(filepath.Join(tmpDir, deviceIDFileName), 0o755) + require.NoError(t, err) + + _, err = getOrCreateDeviceID() + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist)) + }) + + t.Run("creates state directory if missing", func(t *testing.T) { + tmpDir := t.TempDir() + nestedDir := filepath.Join(tmpDir, "nested", "state") + t.Cleanup(stubStateDir(nestedDir)) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + require.NotEmpty(t, id) + + data, err := os.ReadFile(filepath.Join(nestedDir, deviceIDFileName)) + require.NoError(t, err) + assert.Equal(t, id, string(data)) + }) + + t.Run("concurrent callers converge on the same ID", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + const goroutines = 10 + ids := make([]string, goroutines) + errs := make([]error, goroutines) + var wg sync.WaitGroup + wg.Add(goroutines) + for i := range goroutines { + go func() { + defer wg.Done() + ids[i], errs[i] = getOrCreateDeviceID() + }() + } + wg.Wait() + + for i := range goroutines { + require.NoError(t, errs[i]) + } + for i := 1; i < goroutines; i++ { + assert.Equal(t, ids[0], ids[i], "goroutine %d returned a different ID", i) + } + }) +} + +func TestParseTelemetryState(t *testing.T) { + envSet := func(val string) func(string) (string, bool) { + return func(string) (string, bool) { return val, true } + } + envUnset := func(string) (string, bool) { return "", false } + + // envMap allows setting multiple environment variables for testing DO_NOT_TRACK + GH_TELEMETRY interactions. + envMap := func(m map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + val, ok := m[key] + return val, ok + } + } + + tests := []struct { + name string + lookupEnv func(string) (string, bool) + configValue string + want TelemetryState + }{ + { + name: "env unset, config empty string disables", + lookupEnv: envUnset, + configValue: "", + want: Disabled, + }, + { + name: "env unset, config enabled", + lookupEnv: envUnset, + configValue: "enabled", + want: Enabled, + }, + { + name: "env unset, config disabled", + lookupEnv: envUnset, + configValue: "disabled", + want: Disabled, + }, + { + name: "env unset, config log", + lookupEnv: envUnset, + configValue: "log", + want: Logged, + }, + { + name: "env unset, config false", + lookupEnv: envUnset, + configValue: "false", + want: Disabled, + }, + { + name: "env unset, config any truthy value", + lookupEnv: envUnset, + configValue: "anything", + want: Enabled, + }, + { + name: "env enabled takes precedence over config disabled", + lookupEnv: envSet("enabled"), + configValue: "disabled", + want: Enabled, + }, + { + name: "env disabled takes precedence over config enabled", + lookupEnv: envSet("disabled"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env log takes precedence over config enabled", + lookupEnv: envSet("log"), + configValue: "enabled", + want: Logged, + }, + { + name: "env false disables", + lookupEnv: envSet("false"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env empty string disables", + lookupEnv: envSet(""), + configValue: "enabled", + want: Disabled, + }, + { + name: "env any truthy value enables", + lookupEnv: envSet("yes"), + configValue: "disabled", + want: Enabled, + }, + { + name: "env FALSE (uppercase) disables", + lookupEnv: envSet("FALSE"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env LOG (uppercase) logs", + lookupEnv: envSet("LOG"), + configValue: "enabled", + want: Logged, + }, + { + name: "env value with whitespace is trimmed", + lookupEnv: envSet(" false "), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=1 disables telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=true disables telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "true"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=TRUE disables telemetry (case insensitive)", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "TRUE"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=0 does not disable telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "0"}), + configValue: "enabled", + want: Enabled, + }, + { + name: "DO_NOT_TRACK with whitespace is trimmed", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": " 1 "}), + configValue: "enabled", + want: Disabled, + }, + { + name: "GH_TELEMETRY takes precedence over DO_NOT_TRACK", + lookupEnv: envMap(map[string]string{"GH_TELEMETRY": "enabled", "DO_NOT_TRACK": "1"}), + configValue: "", + want: Enabled, + }, + { + name: "DO_NOT_TRACK takes precedence over config", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), + configValue: "log", + want: Disabled, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(stubLookupEnv(tt.lookupEnv)) + got := ParseTelemetryState(tt.configValue) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewServiceLogModeFlushesToWriter(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var buf bytes.Buffer + svc := NewService(LogFlusher(&buf, false)) + + svc.Record(ghtelemetry.Event{ + Type: "test_event", + Dimensions: map[string]string{"key": "value"}, + }) + svc.Flush() + + output := buf.String() + assert.Contains(t, output, "Telemetry payload:") + assert.Contains(t, output, "test_event") + assert.Contains(t, output, `"key"`) + assert.Contains(t, output, `"value"`) +} + +func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var buf bytes.Buffer + svc := NewService(LogFlusher(&buf, true)) + + svc.Record(ghtelemetry.Event{Type: "color_event"}) + svc.Flush() + + output := buf.String() + assert.Contains(t, output, "color_event") + // Verify ANSI color codes are present in the output + assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled") +} + +func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { + t.Run("no color", func(t *testing.T) { + var buf bytes.Buffer + LogFlusher(&buf, false)(SendTelemetryPayload{}) + assert.Equal(t, "Telemetry payload: none\n", buf.String()) + }) + + t.Run("with color", func(t *testing.T) { + var buf bytes.Buffer + LogFlusher(&buf, true)(SendTelemetryPayload{}) + output := buf.String() + assert.Contains(t, output, "Telemetry payload:") + assert.Contains(t, output, "none") + assert.Contains(t, output, "\x1b") // ANSI escape char for color codes + }) +} + +func TestServiceDeviceIDFallback(t *testing.T) { + t.Cleanup(stubDeviceIDError(errors.New("no device id"))) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "", captured.Events[0].Dimensions["device_id"]) +} + +func TestServiceFlush(t *testing.T) { + t.Run("calls flusher with empty payload when no events recorded", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + called := false + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) + svc.Flush() + + assert.True(t, called, "flusher should be called even with no events so log mode can surface the absence") + assert.Empty(t, captured.Events, "payload should have no events") + }) + + t.Run("flushes events with merged dimensions", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"version": "2.45.0"}) + + svc.Record(ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: map[string]string{"command": "gh pr list"}, + Measures: map[string]int64{"duration_ms": 150}, + }) + svc.Flush() + + require.Len(t, captured.Events, 1) + event := captured.Events[0] + assert.Equal(t, "command_invocation", event.Type) + assert.Equal(t, "gh pr list", event.Dimensions["command"]) + assert.Equal(t, "2.45.0", event.Dimensions["version"]) + assert.Equal(t, "test-device", event.Dimensions["device_id"]) + assert.NotEmpty(t, event.Dimensions["timestamp"]) + assert.NotEmpty(t, event.Dimensions["invocation_id"]) + assert.Equal(t, int64(150), event.Measures["duration_ms"]) + }) + + t.Run("flushes multiple events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "event1"}) + svc.Record(ghtelemetry.Event{Type: "event2"}) + svc.Flush() + + require.Len(t, captured.Events, 2) + assert.Equal(t, "event1", captured.Events[0].Type) + assert.Equal(t, "event2", captured.Events[1].Type) + }) + + t.Run("is idempotent", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + callCount := 0 + svc := newService(func(SendTelemetryPayload) { callCount++ }, nil) + svc.Record(ghtelemetry.Event{Type: "test"}) + + svc.Flush() + svc.Flush() + svc.Flush() + + assert.Equal(t, 1, callCount, "flusher should only be called once") + }) + + t.Run("event dimensions override common dimensions", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"shared": "common"}) + + svc.Record(ghtelemetry.Event{ + Type: "test", + Dimensions: map[string]string{"shared": "event-level"}, + }) + svc.Flush() + + require.Len(t, captured.Events, 1) + // Event dimensions are copied last via maps.Copy, so they override common + assert.Equal(t, "event-level", captured.Events[0].Dimensions["shared"]) + }) + + t.Run("timestamps reflect record time not flush time", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "early"}) + time.Sleep(50 * time.Millisecond) + svc.Record(ghtelemetry.Event{Type: "late"}) + svc.Flush() + + require.Len(t, captured.Events, 2) + ts1 := captured.Events[0].Dimensions["timestamp"] + ts2 := captured.Events[1].Dimensions["timestamp"] + require.NotEmpty(t, ts1) + require.NotEmpty(t, ts2) + + t1, err := time.Parse("2006-01-02T15:04:05.000Z", ts1) + require.NoError(t, err) + t2, err := time.Parse("2006-01-02T15:04:05.000Z", ts2) + require.NoError(t, err) + + assert.True(t, t2.After(t1), "second event timestamp %s should be after first %s", ts2, ts1) + }) +} + +func TestServiceSampling(t *testing.T) { + t.Run("sampleRate 0 sends all events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 0 + svc.sampleBucket = 99 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("sampleRate 100 sends all events regardless of bucket", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 100 + svc.sampleBucket = 99 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("bucket below sampleRate sends events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 50 + svc.sampleBucket = 49 // below rate, should be included + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("bucket at sampleRate drops events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleRate = 50 + svc.sampleBucket = 50 // at rate boundary, should be excluded + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when bucket >= sampleRate") + }) + + t.Run("bucket above sampleRate drops events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleRate = 1 + svc.sampleBucket = 50 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when bucket >= sampleRate") + }) + + t.Run("SetSampleRate changes flush behavior", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleBucket = 50 + + // Initially rate=0, which sends everything + svc.SetSampleRate(10) // Now bucket=50 >= rate=10, should drop + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called after SetSampleRate reduced the rate") + }) + + t.Run("SetSampleRate updates sample_rate dimension", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{ + "sample_rate": "1", + }) + svc.sampleRate = 1 + svc.sampleBucket = 0 + + svc.SetSampleRate(100) + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "100", captured.Events[0].Dimensions["sample_rate"]) + }) + + t.Run("WithSampleRate option sets rate on construction", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := NewService(func(SendTelemetryPayload) { called = true }, WithSampleRate(1)) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + // We can't control the bucket from NewService, so we just verify + // the service was created without error and Flush doesn't panic. + // The actual sampling behavior is tested via direct struct manipulation above. + _ = called + }) +} + +func TestWithAdditionalCommonDimensions(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := NewService( + func(p SendTelemetryPayload) { captured = p }, + WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ + "version": "2.45.0", + "agent": "none", + }), + ) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "2.45.0", captured.Events[0].Dimensions["version"]) + assert.Equal(t, "none", captured.Events[0].Dimensions["agent"]) + // Standard common dimensions should also be present + assert.Equal(t, "test-device", captured.Events[0].Dimensions["device_id"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["invocation_id"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["os"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"]) +} + +func TestServiceDisable(t *testing.T) { + t.Run("drops recorded events from flushed payload", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + called := false + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Disable() + svc.Flush() + + assert.True(t, called, "flusher should still be called so log mode can surface the absence of events") + assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") + }) + + t.Run("drops events even with multiple recorded events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + called := false + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) + + svc.Record(ghtelemetry.Event{Type: "event1"}) + svc.Record(ghtelemetry.Event{Type: "event2"}) + svc.Record(ghtelemetry.Event{Type: "event3"}) + svc.Disable() + svc.Flush() + + assert.True(t, called, "flusher should still be called") + assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") + }) + + t.Run("can be called before any events are recorded", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + called := false + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) + + svc.Disable() + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.True(t, called, "flusher should still be called") + assert.Empty(t, captured.Events, "events recorded after Disable() should be dropped") + }) +} + +func TestNoOpService(t *testing.T) { + svc := &NoOpService{} + // All methods should be safe to call without panicking + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Disable() + svc.SetSampleRate(50) + svc.Flush() +} + +func TestSpawnSendTelemetryRejectsOversizedPayload(t *testing.T) { + // Build a payload larger than maxPayloadSize (16KB) + largeDimensions := map[string]string{ + "data": strings.Repeat("x", maxPayloadSize), + } + payload := SendTelemetryPayload{ + Events: []PayloadEvent{ + {Type: "test", Dimensions: largeDimensions}, + }, + } + + // This should not panic or spawn a process - it silently returns. + // We can't easily assert the subprocess wasn't started, but we verify + // the function doesn't crash. + SpawnSendTelemetry("/nonexistent/binary", payload) +} diff --git a/internal/update/update.go b/internal/update/update.go index a4a15ea17cc..20cd09606c8 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/pkg/extensions" "github.com/hashicorp/go-version" "github.com/mattn/go-isatty" @@ -42,7 +43,7 @@ func ShouldCheckForExtensionUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForExtensionUpdate checks whether an update exists for a specific extension based on extension type and recency of last check within past 24 hours. @@ -83,7 +84,7 @@ func ShouldCheckForUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForUpdate checks whether an update exists for the GitHub CLI based on recency of last check within past 24 hours. @@ -182,11 +183,3 @@ func versionGreaterThan(v, w string) bool { func IsTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } - -// IsCI determines if the current execution context is within a known CI/CD system. -// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. -func IsCI() bool { - return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari - os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity - os.Getenv("RUN_ID") != "" // TaskCluster, dsari -} diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go index c5de6c1a481..98105ec14b1 100644 --- a/pkg/cmd/accessibility/accessibility.go +++ b/pkg/cmd/accessibility/accessibility.go @@ -12,7 +12,8 @@ import ( ) const ( - webURL = "https://accessibility.github.com/conformance/cli/" + acrURL = "https://accessibility.github.com/conformance/cli/" + a11yDiscussionsURL = "https://github.com/orgs/community/discussions/categories/accessibility" ) type AccessibilityOptions struct { @@ -36,9 +37,9 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if opts.Web { if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(acrURL)) } - return opts.Browser.Browse(webURL) + return opts.Browser.Browse(acrURL) } return cmd.Help() @@ -138,5 +139,5 @@ func longDescription(io *iostreams.IOStreams) string { feedback and ideas through GitHub Accessibility feedback channels: %[7]s - `, "`", title, color, prompter, spinner, feedback, webURL) + `, "`", title, color, prompter, spinner, feedback, a11yDiscussionsURL) } diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index 2e37d4f5ee2..a09338695b0 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -127,7 +127,7 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen return &j, nil } -// GetJob retrieves a agent job +// GetJob retrieves an agent job func (c *CAPIClient) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) { if owner == "" || repo == "" || jobID == "" { return nil, errors.New("owner, repo, and jobID are required") diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index fb641457f0f..4a87e0f8cb9 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -223,7 +223,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command }, Args: cobra.ExactArgs(1), PreRun: func(c *cobra.Command, args []string) { - opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "") + opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f.BaseRepo, "") }, RunE: func(c *cobra.Command, args []string) error { opts.RequestPath = args[0] diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 469b1a1a1d9..41c713d7c0c 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + neturl "net/url" "strings" "time" @@ -67,18 +68,18 @@ type Client interface { } type LiveClient struct { - githubAPI githubApiClient - httpClient httpClient - host string - logger *ioconfig.Handler + githubAPI githubApiClient + externalHttpClient httpClient + host string + logger *ioconfig.Handler } -func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClient { +func NewLiveClient(hc *http.Client, externalClient *http.Client, host string, l *ioconfig.Handler) *LiveClient { return &LiveClient{ - githubAPI: api.NewClientFromHTTP(hc), - host: strings.TrimSuffix(host, "/"), - httpClient: hc, - logger: l, + githubAPI: api.NewClientFromHTTP(hc), + host: strings.TrimSuffix(host, "/"), + externalHttpClient: externalClient, + logger: l, } } @@ -121,7 +122,7 @@ func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) { // ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96 url = fmt.Sprintf("%s?per_page=%d", url, perPage) if params.PredicateType != "" { - url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType) + url = fmt.Sprintf("%s&predicate_type=%s", url, neturl.QueryEscape(params.PredicateType)) } return url, nil } @@ -225,7 +226,7 @@ func (c *LiveClient) getBundle(url string) (*bundle.Bundle, error) { var sgBundle *bundle.Bundle bo := backoff.NewConstantBackOff(getAttestationRetryInterval) err := backoff.Retry(func() error { - resp, err := c.httpClient.Get(url) + resp, err := c.externalHttpClient.Get(url) if err != nil { return fmt.Errorf("request to fetch bundle from URL failed: %w", err) } diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index d9381612dd2..e27297b51d2 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -28,8 +28,8 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.OnRESTSuccessWithNextPage, }, - httpClient: httpClient, - logger: l, + externalHttpClient: httpClient, + logger: l, } } @@ -37,8 +37,8 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.OnRESTSuccess, }, - httpClient: httpClient, - logger: l, + externalHttpClient: httpClient, + logger: l, } } @@ -137,8 +137,8 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.OnRESTWithNextNoAttestations, }, - httpClient: httpClient, - logger: io.NewTestHandler(), + externalHttpClient: httpClient, + logger: io.NewTestHandler(), } attestations, err := c.GetByDigest(testFetchParamsWithRepo) @@ -167,8 +167,8 @@ func TestGetByDigest_Error(t *testing.T) { func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { httpClient := &mockHttpClient{} client := LiveClient{ - httpClient: httpClient, - logger: io.NewTestHandler(), + externalHttpClient: httpClient, + logger: io.NewTestHandler(), } att1 := makeTestAttestation() @@ -184,8 +184,8 @@ func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing.T) { httpClient := &mockHttpClient{} client := LiveClient{ - httpClient: httpClient, - logger: io.NewTestHandler(), + externalHttpClient: httpClient, + logger: io.NewTestHandler(), } // If both the BundleURL and Bundle fields are empty, the function should @@ -207,8 +207,8 @@ func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) { } c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } att1 := makeTestAttestation() @@ -223,8 +223,8 @@ func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) { mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } a := makeTestAttestation() @@ -239,8 +239,8 @@ func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) { mockHTTPClient := &mockHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } // If the bundle URL is empty, the code will fallback to the bundle field @@ -257,8 +257,8 @@ func TestGetBundle(t *testing.T) { mockHTTPClient := &mockHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("https://mybundleurl.com") @@ -276,8 +276,8 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) { } c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("mybundleurl") @@ -290,8 +290,8 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) { func TestGetBundle_PermanentBackoffFail(t *testing.T) { mockHTTPClient := &invalidBundleClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("mybundleurl") @@ -307,8 +307,8 @@ func TestGetBundle_RequestFail(t *testing.T) { mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("mybundleurl") @@ -360,8 +360,8 @@ func TestGetAttestationsRetries(t *testing.T) { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.FlakyOnRESTSuccessWithNextPageHandler(), }, - httpClient: &mockHttpClient{}, - logger: io.NewTestHandler(), + externalHttpClient: &mockHttpClient{}, + logger: io.NewTestHandler(), } testFetchParamsWithRepo.Limit = 30 diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 8d1d1dc0511..f0024018044 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -84,6 +84,11 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + if opts.Hostname == "" { opts.Hostname, _ = ghauth.DefaultHost() } @@ -91,7 +96,7 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman return err } - opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) + opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) opts.OCIClient = oci.NewLiveClient() opts.Store = NewLiveStore("") diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index 11872daf900..d470c7afbce 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -15,7 +15,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,10 +38,10 @@ func TestNewDownloadCmd(t *testing.T) { f := &cmdutil.Factory{ IOStreams: testIO, HttpClient: func() (*http.Client, error) { - reg := &httpmock.Registry{} - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil + return nil, nil + }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil }, } diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index 9a2bb5d3f58..97aa149fb56 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -86,17 +86,18 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + config := verification.SigstoreConfig{ - HttpClient: hc, - Logger: opts.Logger, + ExternalHttpClient: externalClient, + Logger: opts.Logger, } if ghauth.IsTenancy(opts.Hostname) { - hc, err := f.HttpClient() - if err != nil { - return err - } - apiClient := api.NewLiveClient(hc, opts.Hostname, opts.Logger) + apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) td, err := apiClient.GetTrustDomain() if err != nil { return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err) diff --git a/pkg/cmd/attestation/inspect/inspect_integration_test.go b/pkg/cmd/attestation/inspect/inspect_integration_test.go index 6c56461afa9..7c0f1f65bb6 100644 --- a/pkg/cmd/attestation/inspect/inspect_integration_test.go +++ b/pkg/cmd/attestation/inspect/inspect_integration_test.go @@ -21,6 +21,9 @@ func TestNewInspectCmd_PrintOutputJSONFormat(t *testing.T) { HttpClient: func() (*http.Client, error) { return http.DefaultClient, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return http.DefaultClient, nil + }, } t.Run("Print output in JSON format", func(t *testing.T) { diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index 29dd6fcd9fd..242ebcd1fb3 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -71,6 +71,12 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com if err != nil { return err } + + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + if ghauth.IsTenancy(opts.Hostname) { c, err := f.Config() if err != nil { @@ -81,7 +87,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return fmt.Errorf("not authenticated with %s", opts.Hostname) } logger := io.NewHandler(f.IOStreams) - apiClient := api.NewLiveClient(hc, opts.Hostname, logger) + apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, logger) td, err := apiClient.GetTrustDomain() if err != nil { return err @@ -93,7 +99,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return runF(opts) } - if err := getTrustedRoot(tuf.New, opts, hc); err != nil { + if err := getTrustedRoot(tuf.New, opts, externalClient); err != nil { return fmt.Errorf("Failed to verify the TUF repository: %w", err) } diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index 0d67c44459f..02457a42d80 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -34,6 +34,9 @@ func TestNewTrustedRootCmd(t *testing.T) { httpmock.ReplaceTripper(client, reg) return client, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } testcases := []struct { @@ -120,6 +123,9 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { }, nil }, HttpClient: httpClientFunc, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } cmd := NewTrustedRootCmd(f, func(_ *Options) error { @@ -148,6 +154,9 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { }, nil }, HttpClient: httpClientFunc, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } cmd := NewTrustedRootCmd(f, func(_ *Options) error { diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index e76d55a6b60..7ab3e083172 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -31,10 +31,10 @@ type AttestationProcessingResult struct { } type SigstoreConfig struct { - TrustedRoot string - Logger *io.Handler - NoPublicGood bool - HttpClient *http.Client + TrustedRoot string + Logger *io.Handler + NoPublicGood bool + ExternalHttpClient *http.Client // If tenancy mode is not used, trust domain is empty TrustDomain string // TUFMetadataDir @@ -76,7 +76,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro // No custom trusted root is set, so configure Public Good and GitHub verifiers if !config.NoPublicGood { - publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.HttpClient) + publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.ExternalHttpClient) if err != nil { // Log warning but continue - PGI unavailability should not block GitHub attestation verification config.Logger.VerbosePrintf("Warning: failed to initialize Sigstore Public Good verifier: %v\n", err) @@ -86,7 +86,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro } } - github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.HttpClient) + github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.ExternalHttpClient) if err != nil { config.Logger.VerbosePrintf("Warning: failed to initialize GitHub verifier: %v\n", err) } else { diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go index d37b94fc835..daa948b950c 100644 --- a/pkg/cmd/attestation/verification/sigstore_integration_test.go +++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go @@ -52,9 +52,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -73,9 +73,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("with 2/3 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -92,9 +92,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("fail with 0/2 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -118,9 +118,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -133,10 +133,10 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go index ec3eb271cb1..bb92489c63f 100644 --- a/pkg/cmd/attestation/verify/attestation_integration_test.go +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -27,9 +27,9 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { func TestVerifyAttestations(t *testing.T) { sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 90cc5643c36..120f94d6588 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -173,6 +173,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + opts.OCIClient = oci.NewLiveClient() if opts.Hostname == "" { @@ -183,13 +188,13 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } - opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) + opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) config := verification.SigstoreConfig{ - HttpClient: hc, - Logger: opts.Logger, - NoPublicGood: opts.NoPublicGood, - TrustedRoot: opts.TrustedRoot, + ExternalHttpClient: externalClient, + Logger: opts.Logger, + NoPublicGood: opts.NoPublicGood, + TrustedRoot: opts.TrustedRoot, } // Prepare for tenancy if detected diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index ec64cefa7a3..195313b645e 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -6,12 +6,16 @@ import ( "net/http" "testing" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/cli/v2/pkg/iostreams" o "github.com/cli/cli/v2/pkg/option" "github.com/cli/go-gh/v2/pkg/auth" "github.com/stretchr/testify/require" @@ -21,24 +25,27 @@ func TestVerifyIntegration(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) publicGoodOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha512", @@ -113,7 +120,7 @@ func TestVerifyIntegration(t *testing.T) { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) opts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9", UseBundleFromRegistry: true, DigestAlgorithm: "sha256", @@ -138,24 +145,27 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha256", @@ -212,24 +222,28 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + cfg := config.NewBlankConfig() + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + func() (gh.Config, error) { return cfg, nil }, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha256", @@ -305,27 +319,33 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + cfg := config.NewBlankConfig() + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + func() (gh.Config, error) { return cfg, nil }, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), - ArtifactPath: artifactPath, - BundlePath: bundlePath, - Config: cmdFactory.Config, + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), + ArtifactPath: artifactPath, + BundlePath: bundlePath, + Config: func() (gh.Config, error) { + return cfg, nil + }, DigestAlgorithm: "sha256", Logger: logger, OCIClient: oci.NewLiveClient(), diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 2b821a435d9..295d4a30a30 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -54,6 +54,9 @@ func TestNewVerifyCmd(t *testing.T) { httpmock.ReplaceTripper(client, reg) return client, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } testcases := []struct { diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index 70e01653f14..e8154f42495 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -31,5 +31,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authTokenCmd.NewCmdToken(f, nil)) cmd.AddCommand(authSwitchCmd.NewCmdSwitch(f, nil)) + cmdutil.DisableTelemetryForSubcommands(cmd) + return cmd } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 88bc09f63a0..24d30c56244 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -138,7 +138,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm opts.Hostname, _ = ghauth.DefaultHost() } - opts.MainExecutable = f.Executable() + opts.MainExecutable = f.ExecutablePath if runF != nil { return runF(opts) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index c025df465b8..842902502cd 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -101,7 +101,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. return cmdutil.FlagErrorf("--hostname required when not running interactively") } - opts.MainExecutable = f.Executable() + opts.MainExecutable = f.ExecutablePath if runF != nil { return runF(opts) } diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index 0ff7b690360..a146a579fb9 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -53,7 +53,7 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr `), RunE: func(cmd *cobra.Command, args []string) error { opts.CredentialsHelperConfig = &gitcredentials.HelperConfig{ - SelfExecutablePath: f.Executable(), + SelfExecutablePath: f.ExecutablePath, GitClient: f.GitClient, } if opts.Hostname == "" && opts.Force { diff --git a/pkg/cmd/auth/shared/writeable.go b/pkg/cmd/auth/shared/writeable.go index 381c7e02a66..bcc7da14e42 100644 --- a/pkg/cmd/auth/shared/writeable.go +++ b/pkg/cmd/auth/shared/writeable.go @@ -6,6 +6,12 @@ import ( "github.com/cli/cli/v2/internal/gh" ) +// AuthTokenRefreshable reports whether the token is stored by gh and can be +// renewed with `gh auth refresh`. +func AuthTokenRefreshable(token, src string) bool { + return token != "" && !strings.HasSuffix(src, "_TOKEN") && strings.HasPrefix(token, "gho_") +} + func AuthTokenWriteable(authCfg gh.AuthConfig, hostname string) (string, bool) { token, src := authCfg.ActiveToken(hostname) return src, (token == "" || !strings.HasSuffix(src, "_TOKEN")) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 348b9531d73..658a8d8bc79 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -96,6 +96,9 @@ func (e authEntry) String(cs *iostreams.ColorScheme) string { sb.WriteString(fmt.Sprintf(" - The token in %s is invalid.\n", e.TokenSource)) if authTokenWriteable(e.TokenSource) { loginInstructions := fmt.Sprintf("gh auth login -h %s", e.Host) + if shared.AuthTokenRefreshable(e.Token, e.TokenSource) { + loginInstructions = fmt.Sprintf("gh auth refresh -h %s", e.Host) + } logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.Host, e.Login) sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s\n", cs.Bold(loginInstructions))) sb.WriteString(fmt.Sprintf(" - To forget about this account, run: %s\n", cs.Bold(logoutInstructions))) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 4246b1e863f..cb2abb90ecf 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -184,7 +184,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -229,7 +229,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -447,7 +447,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: false - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -535,7 +535,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe-2 (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe-2 `), }, diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index e56e6c0b86a..0acb89a511d 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -82,7 +82,7 @@ type apiClient interface { ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []api.DevContainerEntry, err error) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*api.User, error) - HTTPClient() (*http.Client, error) + ExternalHTTPClient() (*http.Client, error) } var errNoCodespaces = errors.New("you have no codespaces") diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go index e4de0ae7a90..7dc5bf6ce94 100644 --- a/pkg/cmd/codespace/mock_api.go +++ b/pkg/cmd/codespace/mock_api.go @@ -26,6 +26,9 @@ import ( // EditCodespaceFunc: func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) { // panic("mock out the EditCodespace method") // }, +// ExternalHTTPClientFunc: func() (*http.Client, error) { +// panic("mock out the ExternalHTTPClient method") +// }, // GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) { // panic("mock out the GetCodespace method") // }, @@ -53,9 +56,6 @@ import ( // GetUserFunc: func(ctx context.Context) (*codespacesAPI.User, error) { // panic("mock out the GetUser method") // }, -// HTTPClientFunc: func() (*http.Client, error) { -// panic("mock out the HTTPClient method") -// }, // ListCodespacesFunc: func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) { // panic("mock out the ListCodespaces method") // }, @@ -87,6 +87,9 @@ type apiClientMock struct { // EditCodespaceFunc mocks the EditCodespace method. EditCodespaceFunc func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) + // ExternalHTTPClientFunc mocks the ExternalHTTPClient method. + ExternalHTTPClientFunc func() (*http.Client, error) + // GetCodespaceFunc mocks the GetCodespace method. GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) @@ -114,9 +117,6 @@ type apiClientMock struct { // GetUserFunc mocks the GetUser method. GetUserFunc func(ctx context.Context) (*codespacesAPI.User, error) - // HTTPClientFunc mocks the HTTPClient method. - HTTPClientFunc func() (*http.Client, error) - // ListCodespacesFunc mocks the ListCodespaces method. ListCodespacesFunc func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) @@ -161,6 +161,9 @@ type apiClientMock struct { // Params is the params argument value. Params *codespacesAPI.EditCodespaceParams } + // ExternalHTTPClient holds details about calls to the ExternalHTTPClient method. + ExternalHTTPClient []struct { + } // GetCodespace holds details about calls to the GetCodespace method. GetCodespace []struct { // Ctx is the ctx argument value. @@ -242,9 +245,6 @@ type apiClientMock struct { // Ctx is the ctx argument value. Ctx context.Context } - // HTTPClient holds details about calls to the HTTPClient method. - HTTPClient []struct { - } // ListCodespaces holds details about calls to the ListCodespaces method. ListCodespaces []struct { // Ctx is the ctx argument value. @@ -288,6 +288,7 @@ type apiClientMock struct { lockCreateCodespace sync.RWMutex lockDeleteCodespace sync.RWMutex lockEditCodespace sync.RWMutex + lockExternalHTTPClient sync.RWMutex lockGetCodespace sync.RWMutex lockGetCodespaceBillableOwner sync.RWMutex lockGetCodespaceRepoSuggestions sync.RWMutex @@ -297,7 +298,6 @@ type apiClientMock struct { lockGetOrgMemberCodespace sync.RWMutex lockGetRepository sync.RWMutex lockGetUser sync.RWMutex - lockHTTPClient sync.RWMutex lockListCodespaces sync.RWMutex lockListDevContainers sync.RWMutex lockServerURL sync.RWMutex @@ -425,6 +425,33 @@ func (mock *apiClientMock) EditCodespaceCalls() []struct { return calls } +// ExternalHTTPClient calls ExternalHTTPClientFunc. +func (mock *apiClientMock) ExternalHTTPClient() (*http.Client, error) { + if mock.ExternalHTTPClientFunc == nil { + panic("apiClientMock.ExternalHTTPClientFunc: method is nil but apiClient.ExternalHTTPClient was just called") + } + callInfo := struct { + }{} + mock.lockExternalHTTPClient.Lock() + mock.calls.ExternalHTTPClient = append(mock.calls.ExternalHTTPClient, callInfo) + mock.lockExternalHTTPClient.Unlock() + return mock.ExternalHTTPClientFunc() +} + +// ExternalHTTPClientCalls gets all the calls that were made to ExternalHTTPClient. +// Check the length with: +// +// len(mockedapiClient.ExternalHTTPClientCalls()) +func (mock *apiClientMock) ExternalHTTPClientCalls() []struct { +} { + var calls []struct { + } + mock.lockExternalHTTPClient.RLock() + calls = mock.calls.ExternalHTTPClient + mock.lockExternalHTTPClient.RUnlock() + return calls +} + // GetCodespace calls GetCodespaceFunc. func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) { if mock.GetCodespaceFunc == nil { @@ -785,33 +812,6 @@ func (mock *apiClientMock) GetUserCalls() []struct { return calls } -// HTTPClient calls HTTPClientFunc. -func (mock *apiClientMock) HTTPClient() (*http.Client, error) { - if mock.HTTPClientFunc == nil { - panic("apiClientMock.HTTPClientFunc: method is nil but apiClient.HTTPClient was just called") - } - callInfo := struct { - }{} - mock.lockHTTPClient.Lock() - mock.calls.HTTPClient = append(mock.calls.HTTPClient, callInfo) - mock.lockHTTPClient.Unlock() - return mock.HTTPClientFunc() -} - -// HTTPClientCalls gets all the calls that were made to HTTPClient. -// Check the length with: -// -// len(mockedapiClient.HTTPClientCalls()) -func (mock *apiClientMock) HTTPClientCalls() []struct { -} { - var calls []struct { - } - mock.lockHTTPClient.RLock() - calls = mock.calls.HTTPClient - mock.lockHTTPClient.RUnlock() - return calls -} - // ListCodespaces calls ListCodespacesFunc. func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) { if mock.ListCodespacesFunc == nil { diff --git a/pkg/cmd/codespace/ports_test.go b/pkg/cmd/codespace/ports_test.go index 034c15eb6c6..bd5cfb4f388 100644 --- a/pkg/cmd/codespace/ports_test.go +++ b/pkg/cmd/codespace/ports_test.go @@ -159,7 +159,7 @@ func GetMockApi(allowOrgPorts bool) *apiClientMock { GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { return nil, nil }, - HTTPClientFunc: func() (*http.Client, error) { + ExternalHTTPClientFunc: func() (*http.Client, error) { return connection.NewMockHttpClient() }, } diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index d1675a8f742..5d3bff3d6d8 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -7,6 +7,14 @@ import ( "github.com/spf13/cobra" ) +type ghExecutable struct { + executablePath string +} + +func (e *ghExecutable) Executable() string { + return e.executablePath +} + func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { root := &cobra.Command{ Use: "codespace", @@ -17,7 +25,7 @@ func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { app := NewApp( f.IOStreams, - f, + &ghExecutable{executablePath: f.ExecutablePath}, codespacesAPI.New(f), f.Browser, f.Remotes, diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index b34bf7abfb5..b703fa24952 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -93,6 +93,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { cmdutil.DisableAuthCheck(cmd) cmdutil.StringEnumFlag(cmd, &shellType, "shell", "s", "", []string{"bash", "zsh", "fish", "powershell"}, "Shell type") + cmdutil.DisableTelemetry(cmd) return cmd } diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go index 27260e85783..61d3db35981 100644 --- a/pkg/cmd/config/list/list_test.go +++ b/pkg/cmd/config/list/list_test.go @@ -104,6 +104,7 @@ func Test_listRun(t *testing.T) { accessible_colors=disabled accessible_prompter=disabled spinner=enabled + telemetry=enabled `), }, } diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 4ab840709f1..cc83ef48efa 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -18,10 +18,11 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" - "github.com/cli/cli/v2/internal/update" ghzip "github.com/cli/cli/v2/internal/zip" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -37,7 +38,7 @@ type CopilotOptions struct { Remove bool } -func NewCmdCopilot(f *cmdutil.Factory, runF func(*CopilotOptions) error) *cobra.Command { +func NewCmdCopilot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*CopilotOptions) error) *cobra.Command { opts := &CopilotOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, @@ -80,6 +81,8 @@ func NewCmdCopilot(f *cmdutil.Factory, runF func(*CopilotOptions) error) *cobra. `), DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { + telemetry.SetSampleRate(ghtelemetry.SAMPLE_ALL) + stopParsePos := -1 for i, arg := range args { if arg == "--" { @@ -139,8 +142,9 @@ func runCopilot(opts *CopilotOptions) error { return nil } - copilotPath := findCopilotBinary() - if copilotPath == "" { + copilotPath := findCopilotBinaryFunc() + foundInPath := copilotPath != "" + if !foundInPath { if opts.IO.CanPrompt() { confirmed, err := opts.Prompter.Confirm("GitHub Copilot CLI is not installed. Would you like to install it?", true) if err != nil { @@ -150,7 +154,7 @@ func runCopilot(opts *CopilotOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI was not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } - } else if !update.IsCI() { + } else if !ci.IsCI() { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } @@ -172,12 +176,18 @@ func runCopilot(opts *CopilotOptions) error { externalCmd.Stderr = opts.IO.ErrOut externalCmd.Env = append(os.Environ(), "COPILOT_GH=true") - if err := externalCmd.Run(); err != nil { + if err := runExternalCmdFunc(externalCmd); err != nil { if exitErr, ok := err.(*exec.ExitError); ok { // We terminate with os.Exit here, preserving the exit code from Copilot CLI, // and also preventing stdio writes by callers up the stack. os.Exit(exitErr.ExitCode()) } + if foundInPath { + // We found a `copilot` binary but exec failed, possibly due to + // unusual characters in the path (see https://github.com/cli/cli/issues/13106). + // Suggest running copilot directly as a workaround. + return fmt.Errorf("%w\nFailed to run '%s', try running `copilot` directly without `gh`.", err, copilotPath) + } return err } return nil @@ -197,6 +207,14 @@ func copilotBinaryPath() string { return filepath.Join(copilotInstallDir(), binaryName) } +var runExternalCmdFunc = runExternalCmd + +func runExternalCmd(cmd *exec.Cmd) error { + return cmd.Run() +} + +var findCopilotBinaryFunc = findCopilotBinary + // findCopilotBinary returns the path to the Copilot CLI binary, if installed, // with the following order of precedence: // 1. `copilot` in the PATH diff --git a/pkg/cmd/copilot/copilot_test.go b/pkg/cmd/copilot/copilot_test.go index e7c8fb02755..fa173f5286c 100644 --- a/pkg/cmd/copilot/copilot_test.go +++ b/pkg/cmd/copilot/copilot_test.go @@ -10,10 +10,13 @@ import ( "fmt" "net/http" "os" + "os/exec" "path/filepath" "runtime" "testing" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -110,7 +113,7 @@ func TestNewCmdCopilot(t *testing.T) { assert.NoError(t, err) var gotOpts *CopilotOptions - cmd := NewCmdCopilot(f, func(opts *CopilotOptions) error { + cmd := NewCmdCopilot(f, &telemetry.CommandRecorderSpy{}, func(opts *CopilotOptions) error { gotOpts = opts return nil }) @@ -586,3 +589,45 @@ func TestDownloadCopilot(t *testing.T) { require.Equal(t, localPath, path, "downloadCopilot() path mismatch") }) } + +func TestRunCopilot_execFailureHint(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &CopilotOptions{ + IO: ios, + CopilotArgs: []string{}, + } + + origFind := findCopilotBinaryFunc + findCopilotBinaryFunc = func() string { + return "/usr/bin/copilot" + } + t.Cleanup(func() { findCopilotBinaryFunc = origFind }) + + execErr := fmt.Errorf("exec failed: something went wrong") + origRun := runExternalCmdFunc + runExternalCmdFunc = func(_ *exec.Cmd) error { + return execErr + } + t.Cleanup(func() { runExternalCmdFunc = origRun }) + + err := runCopilot(opts) + require.Error(t, err) + require.ErrorIs(t, err, execErr) + require.Contains(t, err.Error(), "try running `copilot` directly without `gh`.") +} + +func TestCopilotCommandIsSampledAt100(t *testing.T) { + spy := &telemetry.CommandRecorderSpy{} + factory := &cmdutil.Factory{} + cmd := NewCmdCopilot(factory, spy, func(opts *CopilotOptions) error { + return nil + }) + cmd.SetArgs([]string{}) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + require.Equal(t, ghtelemetry.SAMPLE_ALL, spy.LastSampleRate) +} diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 057f911404d..6ec516533d0 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -50,6 +50,10 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { When an extension is executed, gh will check for new versions once every 24 hours and display an upgrade notice. See %[1]sgh help environment%[1]s for information on disabling extension notices. + Extensions are not verified, signed, or endorsed by GitHub. When you install or upgrade + an extension, you are trusting its publisher. It is your responsibility to review the + source and provenance of any extension before use. + For the list of available extensions, see . `, "`"), Aliases: []string{"extensions", "ext"}, @@ -415,6 +419,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().BoolVar(&forceFlag, "force", false, "Force upgrade extension, or ignore if latest already installed") cmd.Flags().StringVar(&pinFlag, "pin", "", "Pin extension to a release tag or commit ref") + cmdutil.DisableAuthCheck(cmd) return cmd }(), func() *cobra.Command { @@ -453,9 +458,10 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return cmd }(), &cobra.Command{ - Use: "remove ", - Short: "Remove an installed extension", - Args: cobra.ExactArgs(1), + Use: "remove ", + Short: "Remove an installed extension", + Aliases: []string{"uninstall"}, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { extName := normalizeExtensionSelector(args[0]) if err := m.Remove(extName); err != nil { diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 7afc6baa758..cc10075f203 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -4,46 +4,44 @@ import ( "context" "fmt" "net/http" - "os" "regexp" - "slices" "time" "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/extension" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - xcolor "github.com/cli/go-gh/v2/pkg/x/color" ) var ssoHeader string var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) -func New(appVersion string, invokingAgent string) *cmdutil.Factory { +func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, executablePath string, telemetryDisabler ghtelemetry.Disabler) *cmdutil.Factory { f := &cmdutil.Factory{ AppVersion: appVersion, InvokingAgent: invokingAgent, - Config: configFunc(), // No factory dependencies - ExecutableName: "gh", + Config: cfgFunc, + ExecutablePath: executablePath, } - f.IOStreams = ioStreams(f) // Depends on Config - f.HttpClient = httpClientFunc(f, appVersion, invokingAgent) // Depends on Config, IOStreams, appVersion, and invokingAgent - f.PlainHttpClient = plainHttpClientFunc(f, appVersion, invokingAgent) // Depends on IOStreams, appVersion, and invokingAgent - f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable - f.Remotes = remotesFunc(f) // Depends on Config, and GitClient - f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes - f.Prompter = newPrompter(f) // Depends on Config and IOStreams - f.Browser = newBrowser(f) // Depends on Config, and IOStreams - f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams - f.Branch = branchFunc(f) // Depends on GitClient + f.IOStreams = ios + f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) + f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) + f.ExternalHttpClient = externalHttpClientFunc(ios, appVersion) + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable + f.Remotes = remotesFunc(f) // Depends on Config, and GitClient + f.BaseRepo = BaseRepoFunc(f.Remotes) + f.Prompter = newPrompter(f) // Depends on Config and IOStreams + f.Browser = newBrowser(f) // Depends on Config, and IOStreams + f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams + f.Branch = branchFunc(f) // Depends on GitClient return f } @@ -73,9 +71,9 @@ func New(appVersion string, invokingAgent string) *cmdutil.Factory { // origin https://github.com/cli/cli-fork.git (push) // // With this resolution function, the upstream will always be chosen (assuming we have authenticated with github.com). -func BaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { +func BaseRepoFunc(remotesFunc func() (ghContext.Remotes, error)) func() (ghrepo.Interface, error) { return func() (ghrepo.Interface, error) { - remotes, err := f.Remotes() + remotes, err := remotesFunc() if err != nil { return nil, err } @@ -187,19 +185,19 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { +func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { - io := f.IOStreams - cfg, err := f.Config() + cfg, err := cfgFunc() if err != nil { return nil, err } opts := api.HTTPClientOptions{ - Config: cfg.Authentication(), - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), - AppVersion: appVersion, - InvokingAgent: invokingAgent, + Config: cfg.Authentication(), + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), + AppVersion: appVersion, + InvokingAgent: invokingAgent, + TelemetryDisabler: telemetryDisabler, } client, err := api.NewHTTPClient(opts) if err != nil { @@ -210,16 +208,16 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) } } -func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { +func plainHttpClientFunc(ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { - io := f.IOStreams opts := api.HTTPClientOptions{ - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), AppVersion: appVersion, InvokingAgent: invokingAgent, // This is required to prevent automatic setting of auth and other headers. SkipDefaultHeaders: true, + TelemetryDisabler: telemetryDisabler, } client, err := api.NewHTTPClient(opts) if err != nil { @@ -229,11 +227,20 @@ func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent st } } +func externalHttpClientFunc(ios *iostreams.IOStreams, appVersion string) func() (*http.Client, error) { + return func() (*http.Client, error) { + return api.NewExternalHTTPClient(api.ExternalHTTPClientOptions{ + AppVersion: appVersion, + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), + }) + } +} + func newGitClient(f *cmdutil.Factory) *git.Client { io := f.IOStreams - ghPath := f.Executable() client := &git.Client{ - GhPath: ghPath, + GhPath: f.ExecutablePath, Stderr: io.ErrOut, Stdin: io.In, Stdout: io.Out, @@ -252,18 +259,6 @@ func newPrompter(f *cmdutil.Factory) prompter.Prompter { return prompter.New(editor, io) } -func configFunc() func() (gh.Config, error) { - var cachedConfig gh.Config - var configError error - return func() (gh.Config, error) { - if cachedConfig != nil || configError != nil { - return cachedConfig, configError - } - cachedConfig, configError = config.NewConfig() - return cachedConfig, configError - } -} - func branchFunc(f *cmdutil.Factory) func() (string, error) { return func() (string, error) { currentBranch, err := f.GitClient.CurrentBranch(context.Background()) @@ -293,72 +288,6 @@ func extensionManager(f *cmdutil.Factory) *extension.Manager { return em } -func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { - io := iostreams.System() - cfg, err := f.Config() - if err != nil { - return io - } - - if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled { - io.SetNeverPrompt(true) - } else if prompt := cfg.Prompt(""); prompt.Value == "disabled" { - io.SetNeverPrompt(true) - } - - falseyValues := []string{"false", "0", "no", ""} - - accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") - if accessiblePrompterIsSet { - if !slices.Contains(falseyValues, accessiblePrompterValue) { - io.SetAccessiblePrompterEnabled(true) - } - } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { - io.SetAccessiblePrompterEnabled(true) - } - - experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") - if experimentalPrompterIsSet { - if !slices.Contains(falseyValues, experimentalPrompterValue) { - io.SetExperimentalPrompterEnabled(true) - } - } - - ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") - if ghSpinnerDisabledIsSet { - if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { - io.SetSpinnerDisabled(true) - } - } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { - io.SetSpinnerDisabled(true) - } - - // Pager precedence - // 1. GH_PAGER - // 2. pager from config - // 3. PAGER - if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists { - io.SetPager(ghPager) - } else if pager := cfg.Pager(""); pager.Value != "" { - io.SetPager(pager.Value) - } - - if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { - switch ghColorLabels { - case "", "0", "false", "no": - io.SetColorLabels(false) - default: - io.SetColorLabels(true) - } - } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { - io.SetColorLabels(true) - } - - io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) - - return io -} - // SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader // to extract the value of the "X-GitHub-SSO" response header. func SSOURL() string { diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 7d84caa8f26..9cf34f3b0e5 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -66,7 +67,6 @@ func Test_BaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -90,8 +90,10 @@ func Test_BaseRepo(t *testing.T) { return cfg, nil }, } - f.Remotes = rr.Resolver() - f.BaseRepo = BaseRepoFunc(f) + remotes := rr.Resolver() + f := &cmdutil.Factory{ + BaseRepo: BaseRepoFunc(remotes), + } repo, err := f.BaseRepo() if tt.wantsErr { assert.Error(t, err) @@ -204,7 +206,7 @@ func Test_SmartBaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") + f := &cmdutil.Factory{} rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -297,7 +299,6 @@ func Test_OverrideBaseRepo(t *testing.T) { if tt.envOverride != "" { t.Setenv("GH_REPO", tt.envOverride) } - f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -306,8 +307,10 @@ func Test_OverrideBaseRepo(t *testing.T) { return tt.config, nil }, } - f.Remotes = rr.Resolver() - f.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, tt.argOverride) + remotes := rr.Resolver() + f := &cmdutil.Factory{ + BaseRepo: cmdutil.OverrideBaseRepoFunc(BaseRepoFunc(remotes), tt.argOverride), + } repo, err := f.BaseRepo() if tt.wantsErr { assert.Error(t, err) @@ -321,341 +324,6 @@ func Test_OverrideBaseRepo(t *testing.T) { } } -func Test_ioStreams_pager(t *testing.T) { - tests := []struct { - name string - env map[string]string - config gh.Config - wantPager string - }{ - { - name: "GH_PAGER and PAGER set", - env: map[string]string{ - "GH_PAGER": "GH_PAGER", - "PAGER": "PAGER", - }, - wantPager: "GH_PAGER", - }, - { - name: "GH_PAGER and config pager set", - env: map[string]string{ - "GH_PAGER": "GH_PAGER", - }, - config: pagerConfig(), - wantPager: "GH_PAGER", - }, - { - name: "config pager and PAGER set", - env: map[string]string{ - "PAGER": "PAGER", - }, - config: pagerConfig(), - wantPager: "CONFIG_PAGER", - }, - { - name: "only PAGER set", - env: map[string]string{ - "PAGER": "PAGER", - }, - wantPager: "PAGER", - }, - { - name: "GH_PAGER set to blank string", - env: map[string]string{ - "GH_PAGER": "", - "PAGER": "PAGER", - }, - wantPager: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.wantPager, io.GetPager()) - }) - } -} - -func Test_ioStreams_prompt(t *testing.T) { - tests := []struct { - name string - config gh.Config - promptDisabled bool - env map[string]string - }{ - { - name: "default config", - promptDisabled: false, - }, - { - name: "config with prompt disabled", - config: disablePromptConfig(), - promptDisabled: true, - }, - { - name: "prompt disabled via GH_PROMPT_DISABLED env var", - env: map[string]string{"GH_PROMPT_DISABLED": "1"}, - promptDisabled: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt()) - }) - } -} - -func Test_ioStreams_spinnerDisabled(t *testing.T) { - tests := []struct { - name string - config gh.Config - spinnerDisabled bool - env map[string]string - }{ - { - name: "default config", - spinnerDisabled: false, - }, - { - name: "config with spinner disabled", - config: disableSpinnersConfig(), - spinnerDisabled: true, - }, - { - name: "config with spinner enabled", - config: enableSpinnersConfig(), - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", - env: map[string]string{"GH_SPINNER_DISABLED": "0"}, - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = false", - env: map[string]string{"GH_SPINNER_DISABLED": "false"}, - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = no", - env: map[string]string{"GH_SPINNER_DISABLED": "no"}, - spinnerDisabled: false, - }, - { - name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", - env: map[string]string{"GH_SPINNER_DISABLED": "1"}, - spinnerDisabled: true, - }, - { - name: "spinner enabled via GH_SPINNER_DISABLED env var = true", - env: map[string]string{"GH_SPINNER_DISABLED": "true"}, - spinnerDisabled: true, - }, - { - name: "config enabled but env disabled, respects env", - config: enableSpinnersConfig(), - env: map[string]string{"GH_SPINNER_DISABLED": "true"}, - spinnerDisabled: true, - }, - { - name: "config disabled but env enabled, respects env", - config: disableSpinnersConfig(), - env: map[string]string{"GH_SPINNER_DISABLED": "false"}, - spinnerDisabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - t.Setenv(k, v) - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) - }) - } -} - -func Test_ioStreams_accessiblePrompterEnabled(t *testing.T) { - tests := []struct { - name string - config gh.Config - accessiblePrompterEnabled bool - env map[string]string - }{ - { - name: "default config", - accessiblePrompterEnabled: false, - }, - { - name: "config with accessible prompter enabled", - config: enableAccessiblePrompterConfig(), - accessiblePrompterEnabled: true, - }, - { - name: "config with accessible prompter disabled", - config: disableAccessiblePrompterConfig(), - accessiblePrompterEnabled: false, - }, - { - name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"}, - accessiblePrompterEnabled: true, - }, - { - name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, - accessiblePrompterEnabled: true, - }, - { - name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"}, - accessiblePrompterEnabled: false, - }, - { - name: "config disabled but env enabled, respects env", - config: disableAccessiblePrompterConfig(), - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, - accessiblePrompterEnabled: true, - }, - { - name: "config enabled but env disabled, respects env", - config: enableAccessiblePrompterConfig(), - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"}, - accessiblePrompterEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - t.Setenv(k, v) - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled()) - }) - } -} - -func Test_ioStreams_colorLabels(t *testing.T) { - tests := []struct { - name string - config gh.Config - colorLabelsEnabled bool - env map[string]string - }{ - { - name: "default config", - colorLabelsEnabled: false, - }, - { - name: "config with colorLabels enabled", - config: enableColorLabelsConfig(), - colorLabelsEnabled: true, - }, - { - name: "config with colorLabels disabled", - config: disableColorLabelsConfig(), - colorLabelsEnabled: false, - }, - { - name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "1"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "true"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "yes"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": ""}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "0"}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "false"}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "no"}, - colorLabelsEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels()) - }) - } -} - func TestSSOURL(t *testing.T) { tests := []struct { name string @@ -683,13 +351,9 @@ func TestSSOURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil - } + cfg := config.NewBlankConfig() ios, _, _, stderr := iostreams.Test() - f.IOStreams = ios - client, err := httpClientFunc(f, "v1.2.3", "")() + client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { @@ -718,13 +382,8 @@ func TestPlainHttpClient(t *testing.T) { })) defer ts.Close() - f := New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil - } ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - client, err := plainHttpClientFunc(f, "v1.2.3", "")() + client, err := plainHttpClientFunc(ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) @@ -759,7 +418,7 @@ func TestNewGitClient(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") + f := &cmdutil.Factory{} f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -767,7 +426,7 @@ func TestNewGitClient(t *testing.T) { return tt.config, nil } } - f.ExecutableName = tt.executable + f.ExecutablePath = tt.executable ios, _, _, _ := iostreams.Test() f.IOStreams = ios c := newGitClient(f) @@ -784,35 +443,3 @@ func defaultConfig() *ghmock.ConfigMock { cfg.Set("nonsense.com", "oauth_token", "BLAH") return cfg } - -func pagerConfig() gh.Config { - return config.NewFromString("pager: CONFIG_PAGER") -} - -func disablePromptConfig() gh.Config { - return config.NewFromString("prompt: disabled") -} - -func enableAccessiblePrompterConfig() gh.Config { - return config.NewFromString("accessible_prompter: enabled") -} - -func disableAccessiblePrompterConfig() gh.Config { - return config.NewFromString("accessible_prompter: disabled") -} - -func disableSpinnersConfig() gh.Config { - return config.NewFromString("spinner: disabled") -} - -func enableSpinnersConfig() gh.Config { - return config.NewFromString("spinner: enabled") -} - -func disableColorLabelsConfig() gh.Config { - return config.NewFromString("color_labels: disabled") -} - -func enableColorLabelsConfig() gh.Config { - return config.NewFromString("color_labels: enabled") -} diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 8e6c6255f36..23f332d7048 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" + issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -47,6 +48,12 @@ type CreateOptions struct { Projects []string Milestone string Template string + + IssueType string + issueTypeID string // resolved during interactive flow to avoid double API call + Parent string + BlockedBy []string + Blocking []string } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -84,6 +91,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co $ gh issue create --assignee "@copilot" $ gh issue create --project "Roadmap" $ gh issue create --template "Bug Report" + $ gh issue create --type Bug + $ gh issue create --parent 100 + $ gh issue create --parent https://github.com/cli/go-gh/issues/42 + $ gh issue create --blocked-by 200,201 --blocking 300 `), Args: cmdutil.NoArgsQuoteReminder, Aliases: []string{"new"}, @@ -141,6 +152,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") cmd.Flags().StringVarP(&opts.Template, "template", "T", "", "Template `name` to use as starting body text") + cmd.Flags().StringVar(&opts.IssueType, "type", "", "Set the issue type by `name`") + cmd.Flags().StringVar(&opts.Parent, "parent", "", "Add the new issue as a sub-issue of the specified parent `number` or URL") + cmd.Flags().StringSliceVar(&opts.BlockedBy, "blocked-by", nil, "Mark the new issue as blocked by these issue `numbers` or URLs") + cmd.Flags().StringSliceVar(&opts.Blocking, "blocking", nil, "Mark the new issue as blocking these issue `numbers` or URLs") return cmd } @@ -289,6 +304,24 @@ func createRun(opts *CreateOptions) (err error) { } } + // Interactive issue type selection + if opts.IssueType == "" { + issueTypes, typesErr := api.RepoIssueTypes(apiClient, baseRepo) + if typesErr == nil && len(issueTypes) > 0 { + typeNames := make([]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + } + var selected int + selected, err = opts.Prompter.Select("Issue type", "", typeNames) + if err != nil { + return + } + opts.IssueType = typeNames[selected] + opts.issueTypeID = issueTypes[selected].ID + } + } + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support) if err != nil { return @@ -379,6 +412,15 @@ func createRun(opts *CreateOptions) (err error) { return } + var updateOpts api.DeferredUpdateIssueOptions + updateOpts, err = deferredUpdateIssueOptions(apiClient, baseRepo, newIssue, opts) + if err != nil { + return + } + if err = api.DeferredUpdateIssue(apiClient, updateOpts); err != nil { + return + } + fmt.Fprintln(opts.IO.Out, newIssue.URL) } else { panic("Unreachable state") @@ -391,3 +433,51 @@ func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prS openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support) } + +// deferredUpdateIssueOptions resolves the user-supplied --type / --parent / +// --blocked-by / --blocking flags into the IDs that DeferredUpdateIssue +// expects. +func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, opts *CreateOptions) (api.DeferredUpdateIssueOptions, error) { + updateOpts := api.DeferredUpdateIssueOptions{ + IssueID: issue.ID, + Hostname: baseRepo.RepoHost(), + } + + if opts.IssueType != "" { + typeID := opts.issueTypeID + if typeID == "" { + var err error + typeID, err = issueShared.ResolveIssueTypeName(client, baseRepo, opts.IssueType) + if err != nil { + return api.DeferredUpdateIssueOptions{}, err + } + } + updateOpts.IssueTypeID = typeID + } + + if opts.Parent != "" { + parentID, err := issueShared.ResolveIssueRef(client, baseRepo, opts.Parent) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --parent reference %q: %w", opts.Parent, err) + } + updateOpts.ParentID = parentID + } + + for _, ref := range opts.BlockedBy { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --blocked-by reference %q: %w", ref, err) + } + updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id) + } + + for _, ref := range opts.Blocking { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --blocking reference %q: %w", ref, err) + } + updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id) + } + + return updateOpts, nil +} diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 4a55a33263a..bd39dfd9bdb 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -198,6 +198,61 @@ func TestNewCmdCreate(t *testing.T) { cli: "--editor", wantsErr: true, }, + { + name: "type", + tty: false, + cli: `-t mytitle -b mybody --type Bug`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + IssueType: "Bug", + }, + }, + { + name: "parent by number", + tty: false, + cli: `-t mytitle -b mybody --parent 100`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + Parent: "100", + }, + }, + { + name: "parent by URL", + tty: false, + cli: `-t mytitle -b mybody --parent https://github.com/cli/go-gh/issues/42`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + Parent: "https://github.com/cli/go-gh/issues/42", + }, + }, + { + name: "blocked by multiple issues", + tty: false, + cli: `-t mytitle -b mybody --blocked-by 200,201`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + BlockedBy: []string{"200", "201"}, + }, + }, + { + name: "blocking another issue", + tty: false, + cli: `-t mytitle -b mybody --blocking 300`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + Blocking: []string{"300"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -247,6 +302,10 @@ func TestNewCmdCreate(t *testing.T) { assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode) assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive) assert.Equal(t, tt.wantsOpts.Template, opts.Template) + assert.Equal(t, tt.wantsOpts.IssueType, opts.IssueType) + assert.Equal(t, tt.wantsOpts.Parent, opts.Parent) + assert.Equal(t, tt.wantsOpts.BlockedBy, opts.BlockedBy) + assert.Equal(t, tt.wantsOpts.Blocking, opts.Blocking) }) } } @@ -255,7 +314,7 @@ func Test_createRun(t *testing.T) { tests := []struct { name string opts CreateOptions - httpStubs func(*httpmock.Registry) + httpStubs func(*testing.T, *httpmock.Registry) promptStubs func(*prompter.PrompterMock) wantsStdout string wantsStderr string @@ -299,7 +358,7 @@ func Test_createRun(t *testing.T) { WebMode: true, Assignees: []string{"@me"}, }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(` @@ -327,7 +386,7 @@ func Test_createRun(t *testing.T) { WebMode: true, Projects: []string{"cleanup"}, }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query RepositoryProjectList\b`), httpmock.StringResponse(` @@ -383,7 +442,7 @@ func Test_createRun(t *testing.T) { Detector: &fd.EnabledDetectorMock{}, WebMode: true, }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueTemplates\b`), httpmock.StringResponse(` @@ -409,7 +468,7 @@ func Test_createRun(t *testing.T) { }, { name: "editor", - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -438,7 +497,7 @@ func Test_createRun(t *testing.T) { }, { name: "editor and template", - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -520,7 +579,7 @@ func Test_createRun(t *testing.T) { } } }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -593,7 +652,7 @@ func Test_createRun(t *testing.T) { } } }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -627,13 +686,254 @@ func Test_createRun(t *testing.T) { wantsStdout: "https://github.com/OWNER/REPO/issues/12\n", wantsStderr: "\nCreating issue in OWNER/REPO\n\n", }, + { + name: "create with type", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "bug title", + Body: "bug body", + IssueType: "Bug", + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" }, + { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" }, + { "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" } + ] } } } }`)) + r.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "ISSUE_ID_123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "ISSUE_ID_123", inputs["issueId"]) + assert.Equal(t, "IT_1", inputs["issueTypeId"]) + })) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, + { + name: "interactive prompts for type", + opts: CreateOptions{ + Interactive: true, + Detector: &fd.EnabledDetectorMock{}, + Title: "feature request", + Body: "would be nice to have", + }, + promptStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "Issue type": + return prompter.IndexFor(options, "Feature") + case "What's next?": + return prompter.IndexFor(options, "Submit") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } }`)) + // Issue types are fetched up front to power the interactive prompt. + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" }, + { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" }, + { "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" } + ] } } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + // Selected ID is reused without re-fetching the types list. + r.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "ISSUE_ID_123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "ISSUE_ID_123", inputs["issueId"]) + assert.Equal(t, "IT_2", inputs["issueTypeId"]) + })) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, + { + name: "create with type not found", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "bug title", + Body: "bug body", + IssueType: "Bugz", + }, + httpStubs: func(_ *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" }, + { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" }, + { "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" } + ] } } } }`)) + }, + wantsErr: `type "Bugz" not found; available types: Bug, Feature, Task`, + }, + { + name: "create with parent", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "child issue", + Body: "child body", + Parent: "100", + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "PARENT_ID_100" } } } }`)) + r.Register( + httpmock.GraphQL(`mutation AddSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "addSubIssue": { "issue": { "id": "PARENT_ID_100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "PARENT_ID_100", inputs["issueId"]) + assert.Equal(t, "ISSUE_ID_123", inputs["subIssueId"]) + assert.Equal(t, false, inputs["replaceParent"]) + })) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, + { + name: "create with blocked-by and blocking", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "blocked issue", + Body: "blocked body", + BlockedBy: []string{"200", "201"}, + Blocking: []string{"300", "301"}, + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + // IssueNodeID lookups for each ref, routed by number so they + // don't depend on parallel ordering. + r.Register( + issueNodeIDByNumberMatcher(200), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKER_ID_200" } } } }`)) + r.Register( + issueNodeIDByNumberMatcher(201), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKER_ID_201" } } } }`)) + r.Register( + issueNodeIDByNumberMatcher(300), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKED_ID_300" } } } }`)) + r.Register( + issueNodeIDByNumberMatcher(301), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKED_ID_301" } } } }`)) + // AddBlockedBy mutations, routed by their inputs so they + // also don't depend on parallel ordering. + // --blocked-by N: this issue is blocked by N + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "ISSUE_ID_123" && input["blockingIssueId"] == "BLOCKER_ID_200" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "ISSUE_ID_123" } } } }`)) + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "ISSUE_ID_123" && input["blockingIssueId"] == "BLOCKER_ID_201" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "ISSUE_ID_123" } } } }`)) + // --blocking N: N is blocked by this issue (args swapped) + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "BLOCKED_ID_300" && input["blockingIssueId"] == "ISSUE_ID_123" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "BLOCKED_ID_300" } } } }`)) + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "BLOCKED_ID_301" && input["blockingIssueId"] == "ISSUE_ID_123" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "BLOCKED_ID_301" } } } }`)) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpReg := &httpmock.Registry{} defer httpReg.Verify(t) if tt.httpStubs != nil { - tt.httpStubs(httpReg) + tt.httpStubs(t, httpReg) } ios, _, stdout, stderr := iostreams.Test() @@ -1402,3 +1702,30 @@ func TestProjectsV1Deprecation(t *testing.T) { }) }) } + +// issueNodeIDByNumberMatcher matches an IssueNodeID GraphQL query whose +// number variable equals the given value. Used by tests that issue +// multiple IssueNodeID lookups and need stubs to route by issue number +// rather than by registration order. +func issueNodeIDByNumberMatcher(number int) httpmock.Matcher { + queryMatcher := httpmock.GraphQL(`query IssueNodeID\b`) + return func(req *http.Request) bool { + if !queryMatcher(req) { + return false + } + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + req.Body = io.NopCloser(bytes.NewReader(body)) + var b struct { + Variables struct { + Number int `json:"number"` + } `json:"variables"` + } + if err := json.Unmarshal(body, &b); err != nil { + return false + } + return b.Variables.Number == number + } +} diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 1d5455504e2..5cf52d92e60 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "sort" + "strings" "sync" "time" @@ -13,7 +14,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" - shared "github.com/cli/cli/v2/pkg/cmd/issue/shared" + issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -35,6 +36,17 @@ type EditOptions struct { IssueNumbers []int Interactive bool + RemoveIssueType bool + + Parent string + RemoveParent bool + AddSubIssues []string + RemoveSubIssues []string + AddBlockedBy []string + RemoveBlockedBy []string + AddBlocking []string + RemoveBlocking []string + prShared.Editable } @@ -76,10 +88,16 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman $ gh issue edit 23 --remove-milestone $ gh issue edit 23 --body-file body.txt $ gh issue edit 23 34 --add-label "help wanted" + $ gh issue edit 23 --type Bug + $ gh issue edit 23 --remove-type + $ gh issue edit 23 --parent 100 + $ gh issue edit 23 --remove-parent + $ gh issue edit 100 --add-sub-issue 123,124 + $ gh issue edit 123 --add-blocked-by 200 --add-blocking 300,301 `), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - issueNumbers, baseRepo, err := shared.ParseIssuesFromArgs(args) + issueNumbers, baseRepo, err := issueShared.ParseIssuesFromArgs(args) if err != nil { return err } @@ -127,6 +145,22 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return err } + if err := cmdutil.MutuallyExclusive( + "specify only one of `--type` or `--remove-type`", + flags.Changed("type"), + opts.RemoveIssueType, + ); err != nil { + return err + } + + if err := cmdutil.MutuallyExclusive( + "specify only one of --parent or --remove-parent", + flags.Changed("parent"), + opts.RemoveParent, + ); err != nil { + return err + } + if flags.Changed("title") { opts.Editable.Title.Edited = true } @@ -147,8 +181,24 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman // which results in milestone association removal. For reference, // see the `Editable.MilestoneId` method. } + if flags.Changed("type") { + opts.Editable.IssueType.Edited = true + } - if !opts.Editable.Dirty() { + // hasDeferredFlags covers edit flags that flow through the + // deferred update path rather than the prShared.Editable struct, + // so they would otherwise be invisible to Editable.Dirty() below. + // Note that --type (set) is intentionally absent: it lights up + // opts.Editable.IssueType.Edited above, which Editable.Dirty() + // already picks up. Only --remove-type needs to be listed here. + hasDeferredFlags := opts.RemoveIssueType || + flags.Changed("parent") || opts.RemoveParent || + len(opts.AddSubIssues) > 0 || len(opts.RemoveSubIssues) > 0 || + len(opts.AddBlockedBy) > 0 || len(opts.RemoveBlockedBy) > 0 || + len(opts.AddBlocking) > 0 || len(opts.RemoveBlocking) > 0 + + // Drop into interactive mode only if the user passed no edit flags at all. + if !opts.Editable.Dirty() && !hasDeferredFlags { opts.Interactive = true } @@ -160,6 +210,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return cmdutil.FlagErrorf("multiple issues cannot be edited interactively") } + if len(opts.IssueNumbers) > 1 && len(opts.AddSubIssues) > 0 { + return cmdutil.FlagErrorf("`--add-sub-issue` cannot be used when editing multiple issues") + } + if runF != nil { return runF(opts) } @@ -179,6 +233,16 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the issue from projects by `title`") cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the issue belongs to by `name`") cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the issue") + cmd.Flags().StringVar(&opts.Editable.IssueType.Value, "type", "", "Set the issue type by `name`") + cmd.Flags().BoolVar(&opts.RemoveIssueType, "remove-type", false, "Remove the issue type from the issue") + cmd.Flags().StringVar(&opts.Parent, "parent", "", "Set the parent issue by `number` or URL") + cmd.Flags().BoolVar(&opts.RemoveParent, "remove-parent", false, "Remove the parent issue") + cmd.Flags().StringSliceVar(&opts.AddSubIssues, "add-sub-issue", nil, "Add sub-issues by `number` or URL") + cmd.Flags().StringSliceVar(&opts.RemoveSubIssues, "remove-sub-issue", nil, "Remove sub-issues by `number` or URL") + cmd.Flags().StringSliceVar(&opts.AddBlockedBy, "add-blocked-by", nil, "Add 'blocked by' relationships by issue `number` or URL") + cmd.Flags().StringSliceVar(&opts.RemoveBlockedBy, "remove-blocked-by", nil, "Remove 'blocked by' relationships by issue `number` or URL") + cmd.Flags().StringSliceVar(&opts.AddBlocking, "add-blocking", nil, "Add 'blocking' relationships by issue `number` or URL") + cmd.Flags().StringSliceVar(&opts.RemoveBlocking, "remove-blocking", nil, "Remove 'blocking' relationships by issue `number` or URL") return cmd } @@ -196,6 +260,7 @@ func editRun(opts *EditOptions) error { // Prompt the user which fields they'd like to edit. editable := opts.Editable + editable.IssueType.Selectable = true if opts.Interactive { err = opts.FieldsToEditSurvey(opts.Prompter, &editable) if err != nil { @@ -239,9 +304,15 @@ func editRun(opts *EditOptions) error { if editable.Milestone.Edited { lookupFields = append(lookupFields, "milestone") } + if editable.IssueType.Edited { + lookupFields = append(lookupFields, "issueType") + } + if opts.Parent != "" || opts.RemoveParent { + lookupFields = append(lookupFields, "parent") + } // Get all specified issues and make sure they are within the same repo. - issues, err := shared.FindIssuesOrPRs(httpClient, baseRepo, opts.IssueNumbers, lookupFields) + issues, err := issueShared.FindIssuesOrPRs(httpClient, baseRepo, opts.IssueNumbers, lookupFields) if err != nil { return err } @@ -272,6 +343,16 @@ func editRun(opts *EditOptions) error { opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating %d issues", len(issues))) } + // Resolve issue type ID up front for non-interactive mode; interactive + // mode resolves after the survey sets the value (inside the loop). + var issueTypeID string + if !opts.Interactive { + issueTypeID, err = lookupIssueTypeID(&editable) + if err != nil { + return err + } + } + for _, issue := range issues { // Copy variables to capture in the go routine below. editable := editable.Clone() @@ -297,6 +378,9 @@ func editRun(opts *EditOptions) error { if issue.Milestone != nil { editable.Milestone.Default = issue.Milestone.Title } + if issue.IssueType != nil { + editable.IssueType.Default = issue.IssueType.Name + } // Allow interactive prompts for one issue; failed earlier if multiple issues specified. if opts.Interactive { @@ -308,17 +392,30 @@ func editRun(opts *EditOptions) error { if err != nil { return err } + issueTypeID, err = lookupIssueTypeID(&editable) + if err != nil { + return err + } } g.Add(1) go func(issue *api.Issue) { defer g.Done() - err := prShared.UpdateIssue(httpClient, baseRepo, issue.ID, issue.IsPullRequest(), editable) + if err := prShared.UpdateIssue(httpClient, baseRepo, issue.ID, issue.IsPullRequest(), editable); err != nil { + failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err) + return + } + + mutations, err := deferredUpdateIssueOptions(apiClient, baseRepo, issue, opts, issueTypeID) if err != nil { failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err) return } + if err := api.DeferredUpdateIssue(apiClient, mutations); err != nil { + failedIssueChan <- fmt.Sprintf("failed to update %s:\n%s", issue.URL, err) + return + } editedIssueChan <- issue.URL }(issue) @@ -359,3 +456,87 @@ func editRun(opts *EditOptions) error { return nil } + +// lookupIssueTypeID resolves the chosen issue type to its node ID using the +// map populated by FetchOptions. +func lookupIssueTypeID(editable *prShared.Editable) (string, error) { + if !editable.IssueType.Edited || editable.IssueType.Value == "" { + return "", nil + } + id, ok := editable.IssueTypeNameToID[editable.IssueType.Value] + if !ok { + return "", fmt.Errorf("type %q not found; available types: %s", + editable.IssueType.Value, + strings.Join(editable.IssueType.Options, ", ")) + } + return id, nil +} + +func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, editOpts *EditOptions, issueTypeID string) (api.DeferredUpdateIssueOptions, error) { + updateOpts := api.DeferredUpdateIssueOptions{ + IssueID: issue.ID, + Hostname: baseRepo.RepoHost(), + IssueTypeID: issueTypeID, + RemoveIssueType: editOpts.RemoveIssueType, + ReplaceExistingParent: true, + } + + if editOpts.RemoveParent { + if issue.Parent != nil { + updateOpts.RemoveParentID = issue.Parent.ID + } + } else if editOpts.Parent != "" { + parentID, err := issueShared.ResolveIssueRef(client, baseRepo, editOpts.Parent) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --parent reference %q: %w", editOpts.Parent, err) + } + updateOpts.ParentID = parentID + } + + for _, ref := range editOpts.AddSubIssues { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-sub-issue reference %q: %w", ref, err) + } + updateOpts.AddSubIssueIDs = append(updateOpts.AddSubIssueIDs, id) + } + for _, ref := range editOpts.RemoveSubIssues { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-sub-issue reference %q: %w", ref, err) + } + updateOpts.RemoveSubIssueIDs = append(updateOpts.RemoveSubIssueIDs, id) + } + + for _, ref := range editOpts.AddBlockedBy { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-blocked-by reference %q: %w", ref, err) + } + updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id) + } + for _, ref := range editOpts.RemoveBlockedBy { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-blocked-by reference %q: %w", ref, err) + } + updateOpts.RemoveBlockedByIDs = append(updateOpts.RemoveBlockedByIDs, id) + } + + for _, ref := range editOpts.AddBlocking { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-blocking reference %q: %w", ref, err) + } + updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id) + } + for _, ref := range editOpts.RemoveBlocking { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-blocking reference %q: %w", ref, err) + } + updateOpts.RemoveBlockingIDs = append(updateOpts.RemoveBlockingIDs, id) + } + + return updateOpts, nil +} diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 626c28162da..d0a188b0c8e 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -2,7 +2,9 @@ package edit import ( "bytes" + "encoding/json" "fmt" + "io" "net/http" "os" "path/filepath" @@ -11,6 +13,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -281,6 +284,106 @@ func TestNewCmdEdit(t *testing.T) { input: "23 34", wantsErr: true, }, + { + name: "type flag", + input: "23 --type Bug", + output: EditOptions{ + IssueNumbers: []int{23}, + Editable: prShared.Editable{ + IssueType: prShared.EditableString{ + Value: "Bug", + Edited: true, + }, + }, + }, + }, + { + name: "remove-type flag", + input: "23 --remove-type", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveIssueType: true, + }, + }, + { + name: "both type and remove-type flags", + input: "23 --type Bug --remove-type", + wantsErr: true, + }, + { + name: "parent flag", + input: "23 --parent 100", + output: EditOptions{ + IssueNumbers: []int{23}, + Parent: "100", + }, + }, + { + name: "remove-parent flag", + input: "23 --remove-parent", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveParent: true, + }, + }, + { + name: "both parent and remove-parent flags", + input: "23 --parent 100 --remove-parent", + wantsErr: true, + }, + { + name: "add-sub-issue flag", + input: "23 --add-sub-issue 123,124", + output: EditOptions{ + IssueNumbers: []int{23}, + AddSubIssues: []string{"123", "124"}, + }, + }, + { + name: "add-sub-issue rejected with multiple issues", + input: "23 24 --add-sub-issue 123", + wantsErr: true, + }, + { + name: "remove-sub-issue flag", + input: "23 --remove-sub-issue 50", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveSubIssues: []string{"50"}, + }, + }, + { + name: "add-blocked-by flag", + input: "23 --add-blocked-by 200", + output: EditOptions{ + IssueNumbers: []int{23}, + AddBlockedBy: []string{"200"}, + }, + }, + { + name: "remove-blocked-by flag", + input: "23 --remove-blocked-by 201", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveBlockedBy: []string{"201"}, + }, + }, + { + name: "add-blocking flag", + input: "23 --add-blocking 300,301", + output: EditOptions{ + IssueNumbers: []int{23}, + AddBlocking: []string{"300", "301"}, + }, + }, + { + name: "remove-blocking flag", + input: "23 --remove-blocking 300", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveBlocking: []string{"300"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -322,6 +425,14 @@ func TestNewCmdEdit(t *testing.T) { assert.Equal(t, tt.output.IssueNumbers, gotOpts.IssueNumbers) assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.Editable, gotOpts.Editable) + assert.Equal(t, tt.output.Parent, gotOpts.Parent) + assert.Equal(t, tt.output.RemoveParent, gotOpts.RemoveParent) + assert.Equal(t, tt.output.AddSubIssues, gotOpts.AddSubIssues) + assert.Equal(t, tt.output.RemoveSubIssues, gotOpts.RemoveSubIssues) + assert.Equal(t, tt.output.AddBlockedBy, gotOpts.AddBlockedBy) + assert.Equal(t, tt.output.RemoveBlockedBy, gotOpts.RemoveBlockedBy) + assert.Equal(t, tt.output.AddBlocking, gotOpts.AddBlocking) + assert.Equal(t, tt.output.RemoveBlocking, gotOpts.RemoveBlocking) if tt.expectedBaseRepo != nil { baseRepo, err := gotOpts.BaseRepo() require.NoError(t, err) @@ -720,6 +831,415 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issue/123\n", }, + { + name: "edit type", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + Editable: prShared.Editable{ + IssueType: prShared.EditableString{ + Value: "Bug", + Edited: true, + }, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" }, + { "id": "FEATURE_TYPE_ID", "name": "Feature", "description": "", "color": "" } + ] } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "BUG_TYPE_ID", inputs["issueTypeId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "remove type", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + RemoveIssueType: true, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Nil(t, inputs["issueTypeId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "interactive edit type prompt", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: true, + FieldsToEditSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable) error { + // Verify the survey is allowed to offer Type as an option for issue edit. + assert.True(t, eo.IssueType.Selectable) + eo.IssueType.Edited = true + return nil + }, + EditFieldsSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable, _ string) error { + // FetchOptions populated Options and IssueTypeNameToID from + // the RepositoryIssueTypes stub below. + assert.Equal(t, []string{"Bug", "Feature"}, eo.IssueType.Options) + assert.Equal(t, "FEATURE_TYPE_ID", eo.IssueTypeNameToID["Feature"]) + eo.IssueType.Value = "Feature" + return nil + }, + FetchOptions: prShared.FetchOptions, + DetermineEditor: func() (string, error) { return "vim", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" }, + { "id": "FEATURE_TYPE_ID", "name": "Feature", "description": "", "color": "" } + ] } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "FEATURE_TYPE_ID", inputs["issueTypeId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit set parent", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + Parent: "100", + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "PARENT_100_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation AddSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "addSubIssue": { "issue": { "id": "PARENT_100_ID" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "PARENT_100_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["subIssueId"]) + assert.Equal(t, true, inputs["replaceParent"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit remove parent", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + RemoveParent: true, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "123", + "number": 123, + "url": "https://github.com/OWNER/REPO/issue/123", + "parent": { + "id": "PARENT_100_ID", + "number": 100, + "title": "Parent Issue", + "url": "https://github.com/OWNER/REPO/issues/100", + "state": "OPEN", + "repository": { "nameWithOwner": "OWNER/REPO" } + } + } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "removeSubIssue": { "issue": { "id": "PARENT_100_ID" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "PARENT_100_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["subIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit add sub-issues", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{100}, + Interactive: false, + AddSubIssues: []string{"123", "124"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueNumberGet(t, reg, 100) + reg.Register( + issueNodeIDByNumberMatcher(123), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } }`), + ) + reg.Register( + issueNodeIDByNumberMatcher(124), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "SUB_124_ID" } } } }`), + ) + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation AddSubIssue\b`, func(input map[string]interface{}) bool { + return input["subIssueId"] == "SUB_123_ID" + }), + httpmock.GraphQLMutation(`{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "100", inputs["issueId"]) + assert.Equal(t, true, inputs["replaceParent"]) + }), + ) + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation AddSubIssue\b`, func(input map[string]interface{}) bool { + return input["subIssueId"] == "SUB_124_ID" + }), + httpmock.GraphQLMutation(`{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "100", inputs["issueId"]) + assert.Equal(t, true, inputs["replaceParent"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/100\n", + }, + { + name: "edit remove sub-issue", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{100}, + Interactive: false, + RemoveSubIssues: []string{"123"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueNumberGet(t, reg, 100) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "removeSubIssue": { "issue": { "id": "100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "100", inputs["issueId"]) + assert.Equal(t, "SUB_123_ID", inputs["subIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/100\n", + }, + { + name: "edit add and remove blocked-by", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + AddBlockedBy: []string{"200"}, + RemoveBlockedBy: []string{"201"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKING_200_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation AddBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "addBlockedBy": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "BLOCKING_200_ID", inputs["blockingIssueId"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKING_201_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "removeBlockedBy": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "BLOCKING_201_ID", inputs["blockingIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit add blocking swaps args", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + AddBlocking: []string{"300"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKED_300_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation AddBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "addBlockedBy": { "issue": { "id": "BLOCKED_300_ID" } } } }`, + func(inputs map[string]interface{}) { + // --add-blocking swaps: OTHER issue is blocked BY this issue + assert.Equal(t, "BLOCKED_300_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["blockingIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit remove blocking swaps args", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + RemoveBlocking: []string{"300"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKED_300_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "removeBlockedBy": { "issue": { "id": "BLOCKED_300_ID" } } } }`, + func(inputs map[string]interface{}) { + // --remove-blocking swaps: OTHER issue is no longer blocked BY this issue + assert.Equal(t, "BLOCKED_300_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["blockingIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "batch edit type", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123, 456}, + Interactive: false, + Editable: prShared.Editable{ + IssueType: prShared.EditableString{ + Value: "Bug", + Edited: true, + }, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" } + ] } } } } + `), + ) + mockIssueNumberGet(t, reg, 123) + mockIssueNumberGet(t, reg, 456) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) {}), + ) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "456" } } } }`, + func(inputs map[string]interface{}) {}), + ) + }, + stdout: heredoc.Doc(` + https://github.com/OWNER/REPO/issue/123 + https://github.com/OWNER/REPO/issue/456 + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -935,6 +1455,83 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { ) } +// Test_editRun_crossHostRelationshipRefs verifies that every relationship +// flag rejects a cross-host issue URL with the same clear error. Lives as +// its own table rather than additional cases in Test_editRun because each +// case shares identical setup and asserts the same error, varying only in +// which input field carries the cross-host URL. +func Test_editRun_crossHostRelationshipRefs(t *testing.T) { + const crossHostURL = "https://example.com/OWNER/REPO/issues/9" + + // Each case exercises one relationship-bearing flag with a cross-host + // URL. ResolveIssueRef should short-circuit before any GraphQL request, + // and the per-issue failure must surface to stderr. + tests := []struct { + name string + input *EditOptions + }{ + { + name: "set parent", + input: &EditOptions{Parent: crossHostURL}, + }, + { + name: "add sub-issue", + input: &EditOptions{AddSubIssues: []string{crossHostURL}}, + }, + { + name: "remove sub-issue", + input: &EditOptions{RemoveSubIssues: []string{crossHostURL}}, + }, + { + name: "add blocked-by", + input: &EditOptions{AddBlockedBy: []string{crossHostURL}}, + }, + { + name: "remove blocked-by", + input: &EditOptions{RemoveBlockedBy: []string{crossHostURL}}, + }, + { + name: "add blocking", + input: &EditOptions{AddBlocking: []string{crossHostURL}}, + }, + { + name: "remove blocking", + input: &EditOptions{RemoveBlocking: []string{crossHostURL}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + mockIssueGet(t, reg) + // No IssueNodeID stub on purpose: the cross-host guard must + // short-circuit before any resolution request goes out. + + tt.input.Detector = &fd.EnabledDetectorMock{} + tt.input.IssueNumbers = []int{123} + tt.input.Interactive = false + tt.input.FetchOptions = func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + } + tt.input.IO = ios + tt.input.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.input.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + + err := editRun(tt.input) + require.Error(t, err) + assert.Regexp(t, `belongs to a different host \(example\.com\) than the current repository \(github\.com\)`, stderr.String()) + }) + } +} + func TestApiActorsSupported(t *testing.T) { t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -1098,3 +1695,30 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) } + +// issueNodeIDByNumberMatcher matches an IssueNodeID GraphQL query whose +// number variable equals the given value. Used by tests that issue +// multiple IssueNodeID lookups and need stubs to route by issue number +// rather than by registration order. +func issueNodeIDByNumberMatcher(number int) httpmock.Matcher { + queryMatcher := httpmock.GraphQL(`query IssueNodeID\b`) + return func(req *http.Request) bool { + if !queryMatcher(req) { + return false + } + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + req.Body = io.NopCloser(bytes.NewReader(body)) + var b struct { + Variables struct { + Number int `json:"number"` + } `json:"variables"` + } + if err := json.Unmarshal(body, &b); err != nil { + return false + } + return b.Variables.Number == number + } +} diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index d58357ac475..f3e1f7871b9 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -37,6 +37,7 @@ type ListOptions struct { Mention string Milestone string Search string + IssueType string WebMode bool Exporter cmdutil.Exporter @@ -77,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman $ gh issue list --milestone "The big 1.0" $ gh issue list --search "error no:assignee sort:created-asc" $ gh issue list --state all + $ gh issue list --type Bug `), Aliases: []string{"ls"}, Args: cmdutil.NoArgsQuoteReminder, @@ -113,6 +115,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVar(&opts.Mention, "mention", "", "Filter by mention") cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Filter by milestone number or title") cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search issues with `query`") + cmd.Flags().StringVar(&opts.IssueType, "type", "", "Filter by issue type `name`") cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.IssueFields) return cmd @@ -158,6 +161,7 @@ func listRun(opts *ListOptions) error { Mention: opts.Mention, Milestone: opts.Milestone, Search: opts.Search, + IssueType: opts.IssueType, Fields: fields, } @@ -227,7 +231,7 @@ func listRun(opts *ListOptions) error { func issueList(client *http.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { apiClient := api.NewClientFromHTTP(client) - if filters.Search != "" || len(filters.Labels) > 0 || filters.Milestone != "" { + if filters.Search != "" || len(filters.Labels) > 0 || filters.Milestone != "" || filters.IssueType != "" { if milestoneNumber, err := strconv.ParseInt(filters.Milestone, 10, 32); err == nil { milestone, err := milestoneByNumber(client, repo, int32(milestoneNumber)) if err != nil { diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 52f9ba00e25..af0879a6b5e 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -25,6 +25,54 @@ import ( "github.com/stretchr/testify/require" ) +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wantsErr bool + wants ListOptions + }{ + { + name: "type flag", + cli: "--type Bug", + wants: ListOptions{ + IssueType: "Bug", + State: "open", + LimitResults: 30, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + argv, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + + assert.Equal(t, tt.wants.IssueType, gotOpts.IssueType) + assert.Equal(t, tt.wants.State, gotOpts.State) + assert.Equal(t, tt.wants.LimitResults, gotOpts.LimitResults) + }) + } +} + func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) @@ -484,6 +532,41 @@ func Test_issueList(t *testing.T) { })) }, }, + { + name: "with issue type", + args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), + filters: prShared.FilterOptions{ + Entity: "issue", + State: "open", + IssueType: "Bug", + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueSearch\b`), + httpmock.GraphQLQuery(` + { "data": { + "repository": { "hasIssuesEnabled": true }, + "search": { + "issueCount": 0, + "nodes": [] + } + } }`, func(_ string, params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "limit": float64(30), + "query": "repo:OWNER/REPO state:open type:Bug type:issue", + "type": "ISSUE_ADVANCED", + }, params) + })) + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/issue/pin/pin.go b/pkg/cmd/issue/pin/pin.go index 290bec50797..ab5d87fe996 100644 --- a/pkg/cmd/issue/pin/pin.go +++ b/pkg/cmd/issue/pin/pin.go @@ -33,7 +33,7 @@ func NewCmdPin(f *cmdutil.Factory, runF func(*PinOptions) error) *cobra.Command cmd := &cobra.Command{ Use: "pin { | }", - Short: "Pin a issue", + Short: "Pin an issue", Long: heredoc.Doc(` Pin an issue to a repository. diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 63efd61f72e..8501bfcfaa5 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -208,3 +208,42 @@ func FindIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f return resp.Repository.Issue, nil } + +// ResolveIssueRef parses an issue reference (number or URL) and returns its +// node ID. References that point at a different host than baseRepo are +// rejected because relationship mutations require IDs from the base host. +func ResolveIssueRef(client *api.Client, baseRepo ghrepo.Interface, ref string) (string, error) { + number, repo, err := ParseIssueFromArg(ref) + if err != nil { + return "", err + } + + targetRepo := baseRepo + if r, ok := repo.Value(); ok { + if r.RepoHost() != baseRepo.RepoHost() { + return "", fmt.Errorf("issue reference %q belongs to a different host (%s) than the current repository (%s)", ref, r.RepoHost(), baseRepo.RepoHost()) + } + targetRepo = r + } + + return api.IssueNodeID(client, targetRepo, number) +} + +// ResolveIssueTypeName resolves an issue type name to its node ID by +// fetching the repository's available types. +func ResolveIssueTypeName(client *api.Client, repo ghrepo.Interface, typeName string) (string, error) { + issueTypes, err := api.RepoIssueTypes(client, repo) + if err != nil { + return "", err + } + + typeNames := make([]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + if strings.EqualFold(t.Name, typeName) { + return t.ID, nil + } + } + + return "", fmt.Errorf("type %q not found; available types: %s", typeName, strings.Join(typeNames, ", ")) +} diff --git a/pkg/cmd/issue/unpin/unpin.go b/pkg/cmd/issue/unpin/unpin.go index ca22aa82eee..96e801a689e 100644 --- a/pkg/cmd/issue/unpin/unpin.go +++ b/pkg/cmd/issue/unpin/unpin.go @@ -34,7 +34,7 @@ func NewCmdUnpin(f *cmdutil.Factory, runF func(*UnpinOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "unpin { | }", - Short: "Unpin a issue", + Short: "Unpin an issue", Long: heredoc.Doc(` Unpin an issue from a repository. diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 5add5a71b1e..7d562f864b0 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -15,7 +15,6 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" - "github.com/cli/cli/v2/pkg/cmd/issue/shared" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -58,7 +57,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman `, "`"), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + issueNumber, baseRepo, err := issueShared.ParseIssueFromArg(args[0]) if err != nil { return err } @@ -92,6 +91,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman var defaultFields = []string{ "number", "url", "state", "createdAt", "title", "body", "author", "milestone", "assignees", "labels", "reactionGroups", "lastComment", "stateReason", + "issueType", "parent", "subIssues", "subIssuesSummary", } func viewRun(opts *ViewOptions) error { @@ -129,6 +129,12 @@ func viewRun(opts *ViewOptions) error { if projectsV1Support == gh.ProjectsV1Supported { lookupFields.Add("projectCards") } + + // TODO IssueRelationshipsCleanup + issueFeatures, issueErr := opts.Detector.IssueFeatures() + if issueErr == nil && issueFeatures.IssueRelationshipsSupported { + lookupFields.AddValues([]string{"blockedBy", "blocking"}) + } } opts.IO.DetectTerminalTheme() @@ -207,6 +213,24 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { milestoneTitle = issue.Milestone.Title } fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) + var issueTypeName string + if issue.IssueType != nil { + issueTypeName = issue.IssueType.Name + } + fmt.Fprintf(out, "issue-type:\t%s\n", issueTypeName) + var parentRef string + if issue.Parent != nil { + parentRef = formatLinkedIssueRef(issue.Parent) + } + fmt.Fprintf(out, "parent:\t%s\n", parentRef) + fmt.Fprintf(out, "sub-issues:\t%s\n", formatLinkedIssueRefs(issue.SubIssues.Nodes)) + var subIssuesCompleted string + if issue.SubIssuesSummary.Total > 0 { + subIssuesCompleted = fmt.Sprintf("%d/%d", issue.SubIssuesSummary.Completed, issue.SubIssuesSummary.Total) + } + fmt.Fprintf(out, "sub-issues-completed:\t%s\n", subIssuesCompleted) + fmt.Fprintf(out, "blocked-by:\t%s\n", formatLinkedIssueRefs(issue.BlockedBy.Nodes)) + fmt.Fprintf(out, "blocking:\t%s\n", formatLinkedIssueRefs(issue.Blocking.Nodes)) fmt.Fprintf(out, "number:\t%d\n", issue.Number) fmt.Fprintln(out, "--") fmt.Fprintln(out, issue.Body) @@ -219,9 +243,15 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue // Header (Title and State) fmt.Fprintf(out, "%s %s#%d\n", cs.Bold(issue.Title), ghrepo.FullName(baseRepo), issue.Number) + + // State line - include issue type prefix when present + stateLine := issueStateTitleWithColor(cs, issue) + if issue.IssueType != nil { + stateLine = cs.Muted(issue.IssueType.Name) + " · " + stateLine + } fmt.Fprintf(out, "%s • %s opened %s • %s\n", - issueStateTitleWithColor(cs, issue), + stateLine, issue.Author.DisplayName(), text.FuzzyAgo(opts.Now(), issue.CreatedAt), text.Pluralize(issue.Comments.TotalCount, "comment"), @@ -242,6 +272,22 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue fmt.Fprint(out, cs.Bold("Labels: ")) fmt.Fprintln(out, labels) } + if issue.IssueType != nil { + fmt.Fprint(out, cs.Bold("Type: ")) + fmt.Fprintln(out, issue.IssueType.Name) + } + if issue.Parent != nil { + fmt.Fprint(out, cs.Bold("Parent: ")) + fmt.Fprintln(out, formatLinkedIssueRef(issue.Parent)+" "+issue.Parent.Title) + } + if blockedBy := formatLinkedIssueListWithTitle(issue.BlockedBy.Nodes); blockedBy != "" { + fmt.Fprint(out, cs.Bold("Blocked by: ")) + fmt.Fprintln(out, blockedBy) + } + if blocking := formatLinkedIssueListWithTitle(issue.Blocking.Nodes); blocking != "" { + fmt.Fprint(out, cs.Bold("Blocking: ")) + fmt.Fprintln(out, blocking) + } if projects := issueProjectList(*issue); projects != "" { fmt.Fprint(out, cs.Bold("Projects: ")) fmt.Fprintln(out, projects) @@ -266,6 +312,30 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue } fmt.Fprintf(out, "\n%s\n", md) + // Sub-issues section + if issue.SubIssuesSummary.Total > 0 { + fmt.Fprintf(out, "%s · %d/%d (%d%%)\n", + cs.Bold("Sub-issues"), + issue.SubIssuesSummary.Completed, + issue.SubIssuesSummary.Total, + int(issue.SubIssuesSummary.PercentCompleted), + ) + for _, sub := range issue.SubIssues.Nodes { + stateColor := cs.Green + stateLabel := "Open" + if sub.State == "CLOSED" { + stateColor = cs.Magenta + stateLabel = "Closed" + } + fmt.Fprintf(out, "%s %s %s\n", + stateColor(stateLabel), + formatLinkedIssueRef(&sub), + sub.Title, + ) + } + fmt.Fprintln(out) + } + // Comments if issue.Comments.TotalCount > 0 { preview := !opts.Comments @@ -282,6 +352,37 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue return nil } +// formatLinkedIssueRef formats an issue reference as owner/repo#N. +func formatLinkedIssueRef(issue *api.LinkedIssue) string { + return fmt.Sprintf("%s#%d", issue.Repository.NameWithOwner, issue.Number) +} + +// formatLinkedIssueRefs formats a comma-separated list of linked issue +// references without titles. +func formatLinkedIssueRefs(issues []api.LinkedIssue) string { + return joinLinkedIssues(issues, false) +} + +// formatLinkedIssueListWithTitle formats a comma-separated list of linked +// issue references with each title appended after the reference. +func formatLinkedIssueListWithTitle(issues []api.LinkedIssue) string { + return joinLinkedIssues(issues, true) +} + +func joinLinkedIssues(issues []api.LinkedIssue, withTitle bool) string { + if len(issues) == 0 { + return "" + } + parts := make([]string, len(issues)) + for i, issue := range issues { + parts[i] = formatLinkedIssueRef(&issue) + if withTitle { + parts[i] += " " + issue.Title + } + } + return strings.Join(parts, ", ") +} + func issueStateTitleWithColor(cs *iostreams.ColorScheme, issue *api.Issue) string { colorFunc := cs.ColorFromString(prShared.ColorForIssueState(*issue)) state := "Open" diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index aa6002563e8..d11afe8c0b4 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "encoding/json" "io" "net/http" "testing" @@ -21,6 +22,7 @@ import ( "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestJSONFields(t *testing.T) { @@ -46,6 +48,12 @@ func TestJSONFields(t *testing.T) { "url", "isPinned", "stateReason", + "issueType", + "parent", + "subIssues", + "subIssuesSummary", + "blockedBy", + "blocking", }) } @@ -635,3 +643,397 @@ func mockV2ProjectItems(t *testing.T, r *httpmock.Registry) { } } } } } `)) } + +// issueResponseAllIssues2Fields returns a GraphQL response for an issue with all Issues 2.0 fields populated. +func issueResponseAllIssues2Fields() string { + return `{ "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "ISSUE_123", + "number": 123, + "title": "Implement OAuth flow", + "state": "OPEN", + "stateReason": "", + "body": "The OAuth flow needs work.", + "author": {"login": "user1"}, + "createdAt": "2024-01-01T00:00:00Z", + "comments": {"nodes":[], "totalCount": 0}, + "assignees": {"nodes": [], "totalCount": 0}, + "labels": {"nodes": [], "totalCount": 0}, + "milestone": null, + "reactionGroups": [], + "projectCards": {"nodes": [], "totalCount": 0}, + "projectItems": {"nodes": [], "totalCount": 0}, + "url": "https://github.com/OWNER/REPO/issues/123", + "issueType": {"id":"IT_1","name":"Bug","description":"Something is not working","color":"d73a4a"}, + "parent": {"number":100,"title":"Epic: Authentication overhaul","url":"https://github.com/OWNER/REPO/issues/100","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}, + "subIssues": { + "nodes": [ + {"number":101,"title":"Design auth module","url":"https://github.com/OWNER/REPO/issues/101","state":"CLOSED","repository":{"nameWithOwner":"OWNER/REPO"}}, + {"number":102,"title":"Token refresh logic","url":"https://github.com/OWNER/REPO/issues/102","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}} + ], + "totalCount": 2 + }, + "subIssuesSummary": {"total":2,"completed":1,"percentCompleted":50.0}, + "blockedBy": { + "nodes": [{"number":200,"title":"API rate limiting","url":"https://github.com/OWNER/REPO/issues/200","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}], + "totalCount": 1 + }, + "blocking": { + "nodes": [{"number":300,"title":"Release v2.0","url":"https://github.com/OWNER/REPO/issues/300","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}], + "totalCount": 1 + } + } } } }` +} + +// issueResponseNoIssues2Fields returns a GraphQL response for an issue with no Issues 2.0 fields. +func issueResponseNoIssues2Fields() string { + return `{ "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "ISSUE_456", + "number": 456, + "title": "Fix login page", + "state": "OPEN", + "stateReason": "", + "body": "The login page is broken.", + "author": {"login": "user2"}, + "createdAt": "2024-01-01T00:00:00Z", + "comments": {"nodes":[], "totalCount": 2}, + "assignees": {"nodes": [], "totalCount": 0}, + "labels": {"nodes": [], "totalCount": 0}, + "milestone": null, + "reactionGroups": [], + "projectCards": {"nodes": [], "totalCount": 0}, + "projectItems": {"nodes": [], "totalCount": 0}, + "url": "https://github.com/OWNER/REPO/issues/456" + } } } }` +} + +func TestIssueView_tty_Issues2AllFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 123, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + // Title + assert.Contains(t, out, "Implement OAuth flow") + assert.Contains(t, out, "OWNER/REPO#123") + + // State line includes issue type prefix + assert.Contains(t, out, "Bug · Open") + + // Type metadata row + assert.Contains(t, out, "Type:") + assert.Contains(t, out, "Bug") + + // Parent metadata row + assert.Contains(t, out, "Parent:") + assert.Contains(t, out, "OWNER/REPO#100 Epic: Authentication overhaul") + + // Blocked by metadata row + assert.Contains(t, out, "Blocked by:") + assert.Contains(t, out, "OWNER/REPO#200 API rate limiting") + + // Blocking metadata row + assert.Contains(t, out, "Blocking:") + assert.Contains(t, out, "OWNER/REPO#300 Release v2.0") + + // Sub-issues section + assert.Contains(t, out, "Sub-issues") + assert.Contains(t, out, "1/2 (50%)") + assert.Contains(t, out, "OWNER/REPO#101") + assert.Contains(t, out, "Design auth module") + assert.Contains(t, out, "OWNER/REPO#102") + assert.Contains(t, out, "Token refresh logic") + + // Body + assert.Contains(t, out, "The OAuth flow needs work.") + + // Footer + assert.Contains(t, out, "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123") +} + +func TestIssueView_nontty_Issues2AllFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 123, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + assert.Contains(t, out, "issue-type:\tBug\n") + assert.Contains(t, out, "parent:\tOWNER/REPO#100\n") + assert.Contains(t, out, "sub-issues:\tOWNER/REPO#101, OWNER/REPO#102\n") + assert.Contains(t, out, "sub-issues-completed:\t1/2\n") + assert.Contains(t, out, "blocked-by:\tOWNER/REPO#200\n") + assert.Contains(t, out, "blocking:\tOWNER/REPO#300\n") +} + +func TestIssueView_tty_Issues2NoFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseNoIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 456, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + // Standard fields are still present + assert.Contains(t, out, "Fix login page") + assert.Contains(t, out, "OWNER/REPO#456") + assert.Contains(t, out, "Open") + assert.Contains(t, out, "The login page is broken.") + assert.Contains(t, out, "View this issue on GitHub: https://github.com/OWNER/REPO/issues/456") + + // Issues 2.0 sections must NOT appear + assert.NotContains(t, out, "Type:") + assert.NotContains(t, out, "Parent:") + assert.NotContains(t, out, "Blocked by:") + assert.NotContains(t, out, "Blocking:") + assert.NotContains(t, out, "Sub-issues") +} + +func TestIssueView_nontty_Issues2NoFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseNoIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 456, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + // Issues 2.0 keys appear with empty values to keep line counts stable + // for `head | grep` workflows. + assert.Contains(t, out, "issue-type:\t\n") + assert.Contains(t, out, "parent:\t\n") + assert.Contains(t, out, "sub-issues:\t\n") + assert.Contains(t, out, "sub-issues-completed:\t\n") + assert.Contains(t, out, "blocked-by:\t\n") + assert.Contains(t, out, "blocking:\t\n") +} + +func TestIssueView_json_IssueType(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + + output, err := runCommand(httpReg, false, `123 --json issueType`) + require.NoError(t, err) + + var data map[string]interface{} + require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data)) + + issueType, ok := data["issueType"].(map[string]interface{}) + require.True(t, ok, "issueType should be an object") + assert.Equal(t, "IT_1", issueType["id"]) + assert.Equal(t, "Bug", issueType["name"]) + assert.Equal(t, "Something is not working", issueType["description"]) + assert.Equal(t, "d73a4a", issueType["color"]) +} + +func TestIssueView_json_ParentSubIssues(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + + output, err := runCommand(httpReg, false, `123 --json parent,subIssues,subIssuesSummary`) + require.NoError(t, err) + + var data map[string]interface{} + require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data)) + + // Parent + parent, ok := data["parent"].(map[string]interface{}) + require.True(t, ok, "parent should be an object") + assert.Equal(t, float64(100), parent["number"]) + assert.Equal(t, "Epic: Authentication overhaul", parent["title"]) + assert.Equal(t, "https://github.com/OWNER/REPO/issues/100", parent["url"]) + assert.Equal(t, "OPEN", parent["state"]) + + // Sub-issues + subIssuesObj, ok := data["subIssues"].(map[string]interface{}) + require.True(t, ok, "subIssues should be an object") + assert.Equal(t, float64(2), subIssuesObj["totalCount"]) + + subIssues, ok := subIssuesObj["nodes"].([]interface{}) + require.True(t, ok, "subIssues.nodes should be an array") + require.Len(t, subIssues, 2) + + sub0 := subIssues[0].(map[string]interface{}) + assert.Equal(t, float64(101), sub0["number"]) + assert.Equal(t, "Design auth module", sub0["title"]) + assert.Equal(t, "CLOSED", sub0["state"]) + + sub1 := subIssues[1].(map[string]interface{}) + assert.Equal(t, float64(102), sub1["number"]) + assert.Equal(t, "Token refresh logic", sub1["title"]) + assert.Equal(t, "OPEN", sub1["state"]) + + // Sub-issues summary + summary, ok := data["subIssuesSummary"].(map[string]interface{}) + require.True(t, ok, "subIssuesSummary should be an object") + assert.Equal(t, float64(2), summary["total"]) + assert.Equal(t, float64(1), summary["completed"]) + assert.Equal(t, float64(50), summary["percentCompleted"]) +} + +func TestIssueView_json_BlockedByBlocking(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + + output, err := runCommand(httpReg, false, `123 --json blockedBy,blocking`) + require.NoError(t, err) + + var data map[string]interface{} + require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data)) + + // Blocked by + blockedByObj, ok := data["blockedBy"].(map[string]interface{}) + require.True(t, ok, "blockedBy should be an object") + assert.Equal(t, float64(1), blockedByObj["totalCount"]) + + blockedBy, ok := blockedByObj["nodes"].([]interface{}) + require.True(t, ok, "blockedBy.nodes should be an array") + require.Len(t, blockedBy, 1) + + blocked0 := blockedBy[0].(map[string]interface{}) + assert.Equal(t, float64(200), blocked0["number"]) + assert.Equal(t, "API rate limiting", blocked0["title"]) + assert.Equal(t, "https://github.com/OWNER/REPO/issues/200", blocked0["url"]) + assert.Equal(t, "OPEN", blocked0["state"]) + + // Blocking + blockingObj, ok := data["blocking"].(map[string]interface{}) + require.True(t, ok, "blocking should be an object") + assert.Equal(t, float64(1), blockingObj["totalCount"]) + + blocking, ok := blockingObj["nodes"].([]interface{}) + require.True(t, ok, "blocking.nodes should be an array") + require.Len(t, blocking, 1) + + blocking0 := blocking[0].(map[string]interface{}) + assert.Equal(t, float64(300), blocking0["number"]) + assert.Equal(t, "Release v2.0", blocking0["title"]) + assert.Equal(t, "https://github.com/OWNER/REPO/issues/300", blocking0["url"]) + assert.Equal(t, "OPEN", blocking0["state"]) +} diff --git a/pkg/cmd/label/clone.go b/pkg/cmd/label/clone.go index a02c4764ad8..b8c4631c61e 100644 --- a/pkg/cmd/label/clone.go +++ b/pkg/cmd/label/clone.go @@ -50,7 +50,7 @@ func newCmdClone(f *cmdutil.Factory, runF func(*cloneOptions) error) *cobra.Comm # Clone and overwrite labels from cli/cli repository into the current repository $ gh label clone cli/cli --force - # Clone labels from cli/cli repository into a octocat/cli repository + # Clone labels from cli/cli repository into octocat/cli repository $ gh label clone cli/cli --repo octocat/cli `), Args: cmdutil.ExactArgs(1, "cannot clone labels: source-repository argument required"), diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 5bad889b675..a622b60c891 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -861,7 +861,7 @@ func Test_createRun(t *testing.T) { { "filename": "template1", "body": "this is a bug" }, { "filename": "template2", - "body": "this is a enhancement" } + "body": "this is an enhancement" } ] } } }`)) reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), @@ -1092,7 +1092,7 @@ func Test_createRun(t *testing.T) { { "filename": "template1", "body": "this is a bug" }, { "filename": "template2", - "body": "this is a enhancement" } + "body": "this is an enhancement" } ] } } }`), ) reg.Register( diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 33a71154ad2..af5e04631f1 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -283,7 +283,7 @@ func editRun(opts *EditOptions) error { } editable := opts.Editable - editable.Reviewers.Allowed = true + editable.Reviewers.Selectable = true editable.Title.Default = pr.Title editable.Body.Default = pr.Body editable.Base.Default = pr.BaseRefName diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index d29b6d4c490..404b0e0cc74 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -21,6 +21,8 @@ type Editable struct { Labels EditableSlice Projects EditableProjects Milestone EditableString + IssueType EditableString + IssueTypeNameToID map[string]string Metadata api.RepoMetadataResult // TODO ApiActorsSupported @@ -36,6 +38,10 @@ type EditableString struct { Default string Options []string Edited bool + // Selectable controls whether the interactive survey offers this + // field as one of the things the user can choose to edit. Flag-only + // fields leave it false. + Selectable bool } type EditableSlice struct { @@ -45,7 +51,10 @@ type EditableSlice struct { Default []string Options []string Edited bool - Allowed bool + // Selectable controls whether the interactive survey offers this + // field as one of the things the user can choose to edit. Flag-only + // fields leave it false. + Selectable bool } // EditableAssignees is a special case of EditableSlice. @@ -75,7 +84,8 @@ func (e Editable) Dirty() bool { e.Assignees.Edited || e.Labels.Edited || e.Projects.Edited || - e.Milestone.Edited + e.Milestone.Edited || + e.IssueType.Edited } func (e Editable) TitleValue() *string { @@ -290,6 +300,8 @@ func (e *Editable) Clone() Editable { Labels: e.Labels.clone(), Projects: e.Projects.clone(), Milestone: e.Milestone.clone(), + IssueType: e.IssueType.clone(), + IssueTypeNameToID: e.IssueTypeNameToID, ApiActorsSupported: e.ApiActorsSupported, // Shallow copy since no mutation. Metadata: e.Metadata, @@ -298,9 +310,10 @@ func (e *Editable) Clone() Editable { func (es *EditableString) clone() EditableString { return EditableString{ - Value: es.Value, - Default: es.Default, - Edited: es.Edited, + Value: es.Value, + Default: es.Default, + Edited: es.Edited, + Selectable: es.Selectable, // Shallow copies since no mutation. Options: es.Options, } @@ -308,8 +321,8 @@ func (es *EditableString) clone() EditableString { func (es *EditableSlice) clone() EditableSlice { cpy := EditableSlice{ - Edited: es.Edited, - Allowed: es.Allowed, + Edited: es.Edited, + Selectable: es.Selectable, // Shallow copies since no mutation. Options: es.Options, // Copy mutable string slices. @@ -443,6 +456,16 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) return err } } + if editable.IssueType.Edited { + if len(editable.IssueType.Options) > 0 { + var selected int + selected, err = p.Select("Type", editable.IssueType.Default, editable.IssueType.Options) + if err != nil { + return err + } + editable.IssueType.Value = editable.IssueType.Options[selected] + } + } confirm, err := p.Confirm("Submit?", true) if err != nil { return err @@ -465,10 +488,14 @@ func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { } opts := []string{"Title", "Body"} - if editable.Reviewers.Allowed { + if editable.Reviewers.Selectable { opts = append(opts, "Reviewers") } - opts = append(opts, "Assignees", "Labels", "Projects", "Milestone") + opts = append(opts, "Assignees", "Labels") + if editable.IssueType.Selectable { + opts = append(opts, "Type") + } + opts = append(opts, "Projects", "Milestone") results, err := multiSelectSurvey(p, "What would you like to edit?", []string{}, opts) if err != nil { return err @@ -489,6 +516,9 @@ func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { if contains(results, "Labels") { editable.Labels.Edited = true } + if contains(results, "Type") { + editable.IssueType.Edited = true + } if contains(results, "Projects") { editable.Projects.Edited = true } @@ -592,6 +622,21 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, editable.Projects.Options = projects editable.Milestone.Options = milestones + // Fetch issue types if editing type + if editable.IssueType.Edited { + issueTypes, err := api.RepoIssueTypes(client, repo) + if err == nil { + typeNames := make([]string, len(issueTypes)) + ids := make(map[string]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + ids[t.Name] = t.ID + } + editable.IssueType.Options = typeNames + editable.IssueTypeNameToID = ids + } + } + return nil } diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 80f1e707de6..ade90653418 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -207,7 +207,6 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err fields := set.NewStringSet() fields.AddValues(opts.Fields) - numberFieldOnly := fields.Len() == 1 && fields.Contains("number") fields.AddValues([]string{"id", "number"}) // for additional preload queries below if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") { @@ -248,11 +247,6 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err var pr *api.PullRequest if f.prNumber > 0 { - // If we have a PR number, let's look it up - if numberFieldOnly { - // avoid hitting the API if we already have all the information - return &api.PullRequest{Number: f.prNumber}, f.baseRefRepo, nil - } pr, err = findByNumber(httpClient, f.baseRefRepo, f.prNumber, fields.ToSlice()) if err != nil { return pr, f.baseRefRepo, err diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index af34370609f..8177fe144ce 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -326,25 +326,6 @@ func TestFind(t *testing.T) { }, wantErr: true, }, - { - name: "number only", - args: args{ - selector: "13", - fields: []string{"number"}, - baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil), - branchFn: func() (string, error) { - return "blueberries", nil - }, - gitConfigClient: stubGitConfigClient{ - readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), - pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), - remotePushDefaultFn: stubRemotePushDefault("", nil), - }, - }, - httpStub: nil, - wantPR: 13, - wantRepo: "https://github.com/ORIGINOWNER/REPO", - }, { name: "pr number zero", args: args{ diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 76f096efd7e..54854db8f29 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -176,6 +176,7 @@ type FilterOptions struct { Entity string Fields []string HeadBranch string + IssueType string Labels []string Mention string Milestone string @@ -212,6 +213,9 @@ func (opts *FilterOptions) IsDefault() bool { if opts.Search != "" { return false } + if opts.IssueType != "" { + return false + } return true } @@ -236,6 +240,7 @@ func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) str case "merged": is = "merged" } + query := search.Query{ Qualifiers: search.Qualifiers{ Assignee: options.Assignee, @@ -243,6 +248,7 @@ func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) str Base: options.BaseBranch, Draft: options.Draft, Head: options.HeadBranch, + IssueType: options.IssueType, Label: options.Labels, Mentions: options.Mention, Milestone: options.Milestone, diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index ddb7a1b2f6b..b777cbc9d68 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -173,6 +173,32 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?q=label%3A%22help+wanted%22+label%3Adocs+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22+state%3Aopen+type%3Apr", wantErr: false, }, + { + name: "issue type", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "issue", + State: "open", + IssueType: "Bug", + }, + }, + want: "https://example.com/path?q=state%3Aopen+type%3ABug+type%3Aissue", + wantErr: false, + }, + { + name: "issue type with spaces is quoted", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "issue", + State: "open", + IssueType: `Hot "Spicy" Bug`, + }, + }, + want: "https://example.com/path?q=state%3Aopen+type%3A%22Hot+%5C%22Spicy%5C%22+Bug%22+type%3Aissue", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 384e5489500..fa0f7ae3b16 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -260,3 +260,36 @@ func TestTitleSurvey(t *testing.T) { }) } } + +func TestFieldsToEditSurvey_IssueOnlyFields(t *testing.T) { + t.Run("without Allowed flag omits Type", func(t *testing.T) { + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to edit?", []string{}, + // Type should NOT appear here + []string{"Title", "Body", "Assignees", "Labels", "Projects", "Milestone"}, + func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) + + editable := &Editable{} + err := FieldsToEditSurvey(pm, editable) + require.NoError(t, err) + assert.True(t, editable.Title.Edited) + }) + + t.Run("with Allowed flag includes Type", func(t *testing.T) { + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to edit?", []string{}, + // Type should appear between Labels and Projects + []string{"Title", "Body", "Assignees", "Labels", "Type", "Projects", "Milestone"}, + func(_ string, _, _ []string) ([]int, error) { + return []int{4}, nil // select Type + }) + + editable := &Editable{} + editable.IssueType.Selectable = true + err := FieldsToEditSurvey(pm, editable) + require.NoError(t, err) + assert.True(t, editable.IssueType.Edited) + }) +} diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index 65990290b67..f26d8eb8b05 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -23,10 +23,10 @@ type Verifier interface { } type AttestationVerifier struct { - AttClient api.Client - HttpClient *http.Client - IO *iostreams.IOStreams - TrustedRoot string + AttClient api.Client + ExternalHttpClient *http.Client + IO *iostreams.IOStreams + TrustedRoot string } func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) { @@ -36,11 +36,11 @@ func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact, } verifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ - HttpClient: v.HttpClient, - Logger: att_io.NewHandler(v.IO), - NoPublicGood: true, - TrustDomain: td, - TrustedRoot: v.TrustedRoot, + ExternalHttpClient: v.ExternalHttpClient, + Logger: att_io.NewHandler(v.IO), + NoPublicGood: true, + TrustDomain: td, + TrustedRoot: v.TrustedRoot, }) if err != nil { return nil, err diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index e1f155d046b..f2e370ecf96 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -170,6 +170,19 @@ func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Inter return ref.Object.SHA, nil } +// DigestAlgForRef returns the digest algorithm name corresponding to the given +// git ref SHA. SHA-1 git object IDs are 40 hex characters and SHA-256 git +// object IDs are 64 hex characters. Unknown lengths default to "sha1" to +// preserve backwards-compatible behavior. +func DigestAlgForRef(digest string) string { + switch len(digest) { + case 64: + return "sha256" + default: + return "sha1" + } +} + // FetchRelease finds a published repository release by its tagName, or a draft release by its pending tag name. func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { cc, cancel := context.WithCancel(ctx) diff --git a/pkg/cmd/release/shared/fetch_test.go b/pkg/cmd/release/shared/fetch_test.go index 9b4e5df8083..0720b876f6f 100644 --- a/pkg/cmd/release/shared/fetch_test.go +++ b/pkg/cmd/release/shared/fetch_test.go @@ -92,3 +92,38 @@ func TestFetchRefSHA(t *testing.T) { }) } } + +func TestDigestAlgForRef(t *testing.T) { + tests := []struct { + name string + digest string + expected string + }{ + { + name: "sha1 (40 hex chars)", + digest: "1234567890abcdef1234567890abcdef12345678", + expected: "sha1", + }, + { + name: "sha256 (64 hex chars)", + digest: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + expected: "sha256", + }, + { + name: "empty string defaults to sha1", + digest: "", + expected: "sha1", + }, + { + name: "unexpected length defaults to sha1", + digest: "abc", + expected: "sha1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, DigestAlgForRef(tt.digest)) + }) + } +} diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go index acd8a134e8e..9adacf2cae2 100644 --- a/pkg/cmd/release/verify-asset/verify_asset.go +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -83,14 +83,19 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + io := f.IOStreams - attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) attVerifier := &shared.AttestationVerifier{ - AttClient: attClient, - HttpClient: httpClient, - IO: io, - TrustedRoot: opts.TrustedRoot, + AttClient: attClient, + ExternalHttpClient: externalClient, + IO: io, + TrustedRoot: opts.TrustedRoot, } config := &VerifyAssetConfig{ @@ -142,7 +147,7 @@ func verifyAssetRun(config *VerifyAssetConfig) error { return err } - releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, shared.DigestAlgForRef(ref)) // Find attestations for the release tag SHA attestations, err := config.AttClient.GetByDigest(api.FetchParams{ diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go index 530f478ed16..7535735aa40 100644 --- a/pkg/cmd/release/verify-asset/verify_asset_test.go +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -54,6 +54,9 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { HttpClient: func() (*http.Client, error) { return nil, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -166,7 +169,7 @@ func Test_verifyAssetRun_SuccessNoTagArg(t *testing.T) { require.NoError(t, err) } -func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { +func Test_verifyAssetRun_FailedNoAttestations_SHA1(t *testing.T) { ios, _, _, _ := iostreams.Test() tagName := "v1" @@ -180,6 +183,55 @@ func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: attClient, + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "no attestations found for tag v1") + require.ErrorContains(t, err, "sha1:"+fakeSHA) + require.Equal(t, "sha1:"+fakeSHA, capturedParams.Digest) +} + +func Test_verifyAssetRun_FailedNoAttestations_SHA256(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + cfg := &VerifyAssetConfig{ Opts: &VerifyAssetOptions{ AssetFilePath: releaseAssetPath, @@ -189,12 +241,14 @@ func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, - AttClient: api.NewFailTestClient(), + AttClient: attClient, AttVerifier: nil, } err = verifyAssetRun(cfg) require.ErrorContains(t, err, "no attestations found for tag v1") + require.ErrorContains(t, err, "sha256:"+fakeSHA) + require.Equal(t, "sha256:"+fakeSHA, capturedParams.Digest) } func Test_verifyAssetRun_FailedTagNotInAttestation(t *testing.T) { diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 65516764ebe..c1a9ae4a20a 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -79,14 +79,19 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + io := f.IOStreams - attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) attVerifier := &shared.AttestationVerifier{ - AttClient: attClient, - HttpClient: httpClient, - IO: io, - TrustedRoot: opts.TrustedRoot, + AttClient: attClient, + ExternalHttpClient: externalClient, + IO: io, + TrustedRoot: opts.TrustedRoot, } config := &VerifyConfig{ @@ -130,7 +135,7 @@ func verifyRun(config *VerifyConfig) error { return err } - releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, shared.DigestAlgForRef(ref)) // Find all the attestations for the release tag SHA attestations, err := config.AttClient.GetByDigest(api.FetchParams{ diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 40009fc7d5a..e2d29bb584b 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -43,6 +43,9 @@ func TestNewCmdVerify_Args(t *testing.T) { HttpClient: func() (*http.Client, error) { return nil, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -103,7 +106,7 @@ func Test_verifyRun_Success(t *testing.T) { require.NoError(t, err) } -func Test_verifyRun_FailedNoAttestations(t *testing.T) { +func Test_verifyRun_FailedNoAttestations_SHA1(t *testing.T) { ios, _, _, _ := iostreams.Test() tagName := "v1" @@ -115,6 +118,52 @@ func Test_verifyRun_FailedNoAttestations(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: attClient, + AttVerifier: nil, + } + + err = verifyRun(cfg) + require.ErrorContains(t, err, "no attestations for tag v1") + require.ErrorContains(t, err, "sha1:"+fakeSHA) + require.Equal(t, "sha1:"+fakeSHA, capturedParams.Digest) +} + +func Test_verifyRun_FailedNoAttestations_SHA256(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + cfg := &VerifyConfig{ Opts: &VerifyOptions{ TagName: tagName, @@ -123,12 +172,14 @@ func Test_verifyRun_FailedNoAttestations(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, - AttClient: api.NewFailTestClient(), + AttClient: attClient, AttVerifier: nil, } err = verifyRun(cfg) require.ErrorContains(t, err, "no attestations for tag v1") + require.ErrorContains(t, err, "sha256:"+fakeSHA) + require.Equal(t, "sha256:"+fakeSHA, capturedParams.Digest) } func Test_verifyRun_FailedTagNotInAttestation(t *testing.T) { diff --git a/pkg/cmd/root/alias.go b/pkg/cmd/root/alias.go index 4f504f2b8c4..ea4c21d8a97 100644 --- a/pkg/cmd/root/alias.go +++ b/pkg/cmd/root/alias.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/findsh" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" @@ -17,7 +18,7 @@ import ( ) func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: aliasName, Short: fmt.Sprintf("Shell alias for %q", text.Truncate(80, aliasValue)), RunE: func(c *cobra.Command, args []string) error { @@ -39,16 +40,19 @@ func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *co } return nil }, - GroupID: "alias", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "alias", DisableFlagParsing: true, } + cmdutil.DisableAuthCheck(cmd) + // Aliases are user-defined names and must not be reported as telemetry + // dimensions, since the name itself may be sensitive (e.g. project or + // organization names). + cmdutil.DisableTelemetry(cmd) + return cmd } func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: aliasName, Short: fmt.Sprintf("Alias for %q", text.Truncate(80, aliasValue)), RunE: func(c *cobra.Command, args []string) error { @@ -60,12 +64,15 @@ func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.C root.SetArgs(expandedArgs) return root.Execute() }, - GroupID: "alias", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "alias", DisableFlagParsing: true, } + cmdutil.DisableAuthCheck(cmd) + // Aliases are user-defined names and must not be reported as telemetry + // dimensions, since the name itself may be sensitive (e.g. project or + // organization names). + cmdutil.DisableTelemetry(cmd) + return cmd } // ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go index 7e2d7aca75f..c432290aa49 100644 --- a/pkg/cmd/root/extension.go +++ b/pkg/cmd/root/extension.go @@ -8,6 +8,7 @@ import ( "time" "github.com/cli/cli/v2/internal/update" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" @@ -26,7 +27,7 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex checkExtensionReleaseInfo = checkForExtensionUpdate } - return &cobra.Command{ + cmd := &cobra.Command{ Use: ext.Name(), Short: fmt.Sprintf("Extension %s", ext.Name()), // PreRun handles looking up whether extension has a latest version only when the command is ran. @@ -73,12 +74,21 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex // This is being handled in non-blocking default as there is no context to cancel like in gh update checks. } }, - GroupID: "extension", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "extension", DisableFlagParsing: true, } + + cmdutil.DisableAuthCheck(cmd) + // Extensions are user-installed and their names can be arbitrary + // (potentially including sensitive identifiers such as project or + // organization names), so we must not record telemetry for them by + // default. Official GitHub-owned extensions are a known, fixed set and + // can safely contribute their command name to telemetry. + if !extensions.IsOfficial(ext.Name(), ext.Owner()) { + cmdutil.DisableTelemetry(cmd) + } + + return cmd } func checkForExtensionUpdate(em extensions.ExtensionManager, ext extensions.Extension) (*update.ReleaseInfo, error) { diff --git a/pkg/cmd/root/extension_registration_test.go b/pkg/cmd/root/extension_registration_test.go index 90b836e4a47..61d73b1b9b9 100644 --- a/pkg/cmd/root/extension_registration_test.go +++ b/pkg/cmd/root/extension_registration_test.go @@ -6,6 +6,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" @@ -56,6 +57,9 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { NameFunc: func() string { return extName }, + OwnerFunc: func() string { + return "" + }, }) } @@ -74,7 +78,7 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { ExtensionManager: em, } - cmd, err := NewCmdRoot(f, "", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "", "") require.NoError(t, err) // Verify skipped extensions (should find core command registered, not extension) diff --git a/pkg/cmd/root/extension_test.go b/pkg/cmd/root/extension_test.go index 5e9e9b9bcf5..9bb5037a593 100644 --- a/pkg/cmd/root/extension_test.go +++ b/pkg/cmd/root/extension_test.go @@ -144,6 +144,9 @@ func TestNewCmdExtension_Updates(t *testing.T) { NameFunc: func() string { return tt.extName }, + OwnerFunc: func() string { + return "" + }, UpdateAvailableFunc: func() bool { return tt.extUpdateAvailable }, @@ -199,6 +202,9 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) { NameFunc: func() string { return "major-update" }, + OwnerFunc: func() string { + return "" + }, UpdateAvailableFunc: func() bool { return true }, @@ -234,3 +240,60 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) { t.Fatal("extension update check should have exited") } } + +func TestNewCmdExtension_TelemetryEnabledForOfficialExtensions(t *testing.T) { + tests := []struct { + name string + extName string + extOwner string + wantTelemetryOff bool + }{ + { + name: "official extension records telemetry", + extName: "stack", + extOwner: "github", + wantTelemetryOff: false, + }, + { + name: "official name with third-party owner disables telemetry", + extName: "stack", + extOwner: "williammartin", + wantTelemetryOff: true, + }, + { + name: "official name with empty owner disables telemetry", + extName: "stack", + extOwner: "", + wantTelemetryOff: true, + }, + { + name: "official extension name with mixed case disables telemetry", + extName: "STACK", + extOwner: "github", + wantTelemetryOff: true, + }, + { + name: "third-party extension disables telemetry", + extName: "my-custom-ext", + extOwner: "someone", + wantTelemetryOff: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + em := &extensions.ExtensionManagerMock{} + ext := &extensions.ExtensionMock{ + NameFunc: func() string { return tt.extName }, + OwnerFunc: func() string { return tt.extOwner }, + } + + cmd := root.NewCmdExtension(ios, em, ext, func(extensions.ExtensionManager, extensions.Extension) (*update.ReleaseInfo, error) { + return nil, nil + }) + + assert.Equal(t, tt.wantTelemetryOff, cmd.Annotations["telemetry"] == "disabled") + }) + } +} diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index 40f333159de..e7f04375845 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" @@ -74,7 +75,7 @@ func TestKramdownCompatibleDocs(t *testing.T) { }, } - cmd, err := NewCmdRoot(f, "N/A", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "N/A", "") require.NoError(t, err) var walk func(*cobra.Command) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index a375d9e2050..0becbf5c81b 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -117,10 +117,26 @@ var HelpTopics = []helpTopic{ %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are more compatible with speech synthesis and braille screen readers. + %[1]sGH_TELEMETRY%[1]s: set to %[1]slog%[1]s to print telemetry data to standard error instead of sending it. + Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry. Takes precedence over %[1]sDO_NOT_TRACK%[1]s. + + %[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry. Ignored when + %[1]sGH_TELEMETRY%[1]s is set, which takes precedence. + %[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with a textual progress indicator. `, "`"), }, + { + name: "telemetry", + short: "Information about telemetry in gh", + long: heredoc.Doc(` + gh collects telemetry to help us understand how the CLI is being used and to improve it. + + To learn more about what data is collected, how it is used, and how to opt out, see: + + `), + }, { name: "reference", short: "A comprehensive reference of all gh commands", diff --git a/pkg/cmd/root/official_extension_stub.go b/pkg/cmd/root/official_extension_stub.go new file mode 100644 index 00000000000..6aa08af936b --- /dev/null +++ b/pkg/cmd/root/official_extension_stub.go @@ -0,0 +1,76 @@ +package root + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ci" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// NewCmdOfficialExtensionStub creates a hidden stub command for an official +// extension that has not yet been installed. When invoked, it suggests +// installing the extension and, in interactive sessions, offers to do so +// immediately. After a successful install, the extension is dispatched with +// the original arguments. +func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command { + cmd := &cobra.Command{ + Use: ext.Name, + Short: fmt.Sprintf("Install the official %s extension", ext.Name), + Hidden: true, + GroupID: "extension", + // Accept any args/flags the user may have passed so we don't get + // cobra validation errors before reaching RunE. + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return officialExtensionStubRun(io, p, em, ext) + }, + } + + cmdutil.DisableAuthCheck(cmd) + + return cmd +} + +func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) error { + stderr := io.ErrOut + + // In CI, skip the prompt so agents and CI runners don't block on Y/n. + if !ci.IsCI() { + if io.CanPrompt() { + prompt := heredoc.Docf(` + %[1]s is available as an official extension. + Would you like to install it now? + `, fmt.Sprintf("gh %s", ext.Name)) + confirmed, err := p.Confirm(prompt, true) + if err != nil { + return err + } + if !confirmed { + return nil + } + } else { + fmt.Fprint(stderr, heredoc.Docf(` + %[1]s is available as an official extension. + To install it, run: + gh extension install %[2]s/%[3]s + `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) + return cmdutil.SilentError + } + } + + repo := ext.Repository() + io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo)) + installErr := em.Install(repo, "") + io.StopProgressIndicator() + if installErr != nil { + return fmt.Errorf("failed to install extension: %w", installErr) + } + + fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo) + return nil +} diff --git a/pkg/cmd/root/official_extension_stub_test.go b/pkg/cmd/root/official_extension_stub_test.go new file mode 100644 index 00000000000..aac50a5c624 --- /dev/null +++ b/pkg/cmd/root/official_extension_stub_test.go @@ -0,0 +1,142 @@ +package root + +import ( + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOfficialExtensionStubRun(t *testing.T) { + ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} + + tests := []struct { + name string + isTTY bool + ciEnv string + confirmResult bool + confirmErr error + installErr error + wantErr string + wantStderr string + wantInstalled bool + }{ + { + name: "non-TTY in CI auto-installs without prompting", + isTTY: false, + ciEnv: "1", + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, + }, + { + name: "non-TTY outside CI prints install instructions and returns silent error", + isTTY: false, + wantStderr: "gh extension install github/gh-cool", + wantErr: "SilentError", + }, + { + name: "TTY confirmed installs", + isTTY: true, + confirmResult: true, + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, + }, + { + name: "TTY in CI auto-installs without prompting", + isTTY: true, + ciEnv: "1", + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, + }, + { + name: "TTY declined does not install", + isTTY: true, + confirmResult: false, + }, + { + name: "TTY prompt error is propagated", + isTTY: true, + confirmErr: fmt.Errorf("prompt interrupted"), + wantErr: "prompt interrupted", + }, + { + name: "TTY install error is propagated", + isTTY: true, + confirmResult: true, + installErr: fmt.Errorf("network error"), + wantErr: "network error", + wantInstalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + if tt.ciEnv != "" { + t.Setenv("CI", tt.ciEnv) + } + + ios, _, _, stderr := iostreams.Test() + if tt.isTTY { + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + } + + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(_ ghrepo.Interface, _ string) error { + return tt.installErr + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return tt.confirmResult, tt.confirmErr + }, + } + + err := officialExtensionStubRun(ios, p, em, ext) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + + if tt.wantInstalled { + require.NotEmpty(t, em.InstallCalls()) + repo := em.InstallCalls()[0].InterfaceMoqParam + assert.Equal(t, "github", repo.RepoOwner()) + assert.Equal(t, "gh-cool", repo.RepoName()) + assert.Equal(t, "github.com", repo.RepoHost()) + } else { + assert.Empty(t, em.InstallCalls()) + } + }) + } +} + +func TestNewCmdOfficialExtensionStub_Properties(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{} + + cmd := NewCmdOfficialExtensionStub(ios, p, em, ext) + + assert.Equal(t, "cool", cmd.Use) + assert.True(t, cmd.Hidden) + assert.Equal(t, "extension", cmd.GroupID) + assert.True(t, cmd.DisableFlagParsing) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ed33f568ed3..4a23fc59ea4 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" agentTaskCmd "github.com/cli/cli/v2/pkg/cmd/agent-task" @@ -38,12 +39,15 @@ import ( runCmd "github.com/cli/cli/v2/pkg/cmd/run" searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" + sendTelemetryCmd "github.com/cli/cli/v2/pkg/cmd/send-telemetry" + skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" statusCmd "github.com/cli/cli/v2/pkg/cmd/status" variableCmd "github.com/cli/cli/v2/pkg/cmd/variable" versionCmd "github.com/cli/cli/v2/pkg/cmd/version" workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/extensions" "github.com/google/shlex" "github.com/spf13/cobra" ) @@ -56,7 +60,7 @@ func (ae *AuthError) Error() string { return ae.err.Error() } -func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, error) { +func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, version, buildDate string) (*cobra.Command, error) { io := f.IOStreams cfg, err := f.Config() if err != nil { @@ -86,6 +90,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } return &AuthError{} } + return nil }, } @@ -144,12 +149,14 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) cmd.AddCommand(previewCmd.NewCmdPreview(f)) + cmd.AddCommand(skillsCmd.NewCmdSkills(f, telemetry)) // Root commands with standalone functionality and no subcommands - cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil)) + cmd.AddCommand(copilotCmd.NewCmdCopilot(f, telemetry, nil)) cmd.AddCommand(statusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(licensesCmd.NewCmdLicenses(f)) + cmd.AddCommand(sendTelemetryCmd.NewCmdSendTelemetry(f)) // below here at the commands that require the "intelligent" BaseRepo resolver repoResolvingCmdFactory := *f @@ -229,7 +236,19 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } } + // Official extension stubs: hidden commands that suggest installing + // GitHub-owned extensions when invoked. Registered after real extensions + // and aliases so that both take priority over stubs. + for i := range extensions.OfficialExtensions { + ext := &extensions.OfficialExtensions[i] + if _, _, err := cmd.Find([]string{ext.Name}); err == nil { + continue + } + cmd.AddCommand(NewCmdOfficialExtensionStub(io, f.Prompter, em, ext)) + } + cmdutil.DisableAuthCheck(cmd) + cmdutil.RecordTelemetryForSubcommands(cmd, telemetry) // The reference command produces paged output that displays information on every other command. // Therefore, we explicitly set the Long text and HelpFunc here after all other commands are registered. diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index bed9e3bfa09..3e5199452e2 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -22,7 +22,9 @@ import ( "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" "github.com/spf13/cobra" + "golang.org/x/text/transform" ) type RunLogCache struct { @@ -579,7 +581,8 @@ func displayLogSegments(w io.Writer, segments []logSegment) error { } func copyLogWithLinePrefix(w io.Writer, r io.Reader, prefix string) error { - scanner := bufio.NewScanner(r) + sanitized := transform.NewReader(r, &asciisanitizer.Sanitizer{}) + scanner := bufio.NewScanner(sanitized) for scanner.Scan() { fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text()) } diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 14749fcf66d..c3ee9a54ad8 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -2759,6 +2759,46 @@ var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLog var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput) var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput) +func TestCopyLogWithLinePrefix_TerminalEscapeSequences(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "OSC title set sequence", + input: "normal prefix\x1b]0;HIJACKED TITLE\x07trailing text\n", + }, + { + name: "CSI color sequence", + input: "\x1b[31mRED TEXT\x1b[0m normal text\n", + }, + { + name: "screen title set sequence used in original report", + input: "\x1bk;echo this is an arbitrary command;\x1b\\\n", + }, + { + name: "CSI window title query", + input: "before\x1b[21tafter\n", + }, + { + name: "multiple escape sequences", + input: "\x1b]0;title\x07\x1b[31mred\x1b[0m\x1b[21t\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := copyLogWithLinePrefix(&buf, strings.NewReader(tt.input), "jobname\tstep\t") + require.NoError(t, err) + + output := buf.String() + assert.NotContains(t, output, "\x1b", + "output should not contain raw ESC (0x1b) bytes, got: %q", output) + }) + } +} + func TestRunLog(t *testing.T) { t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) { cacheDir := t.TempDir() + "/non-existent-dir" diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go index c66e0908f52..bd8060943c2 100644 --- a/pkg/cmd/search/shared/shared_test.go +++ b/pkg/cmd/search/shared/shared_test.go @@ -2,22 +2,27 @@ package shared import ( "fmt" + "net/http" "testing" "time" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/search" "github.com/stretchr/testify/assert" ) func TestSearcher(t *testing.T) { - f := factory.New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil + f := &cmdutil.Factory{ + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{}, nil + }, } _, err := Searcher(f) assert.NoError(t, err) diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go index b73b598488e..b1a5b1d3930 100644 --- a/pkg/cmd/secret/delete/delete.go +++ b/pkg/cmd/secret/delete/delete.go @@ -40,9 +40,9 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co Short: "Delete secrets", Long: heredoc.Doc(` Delete a secret on one of the following levels: - - repository (default): available to GitHub Actions runs or Dependabot in a repository + - repository (default): available to GitHub Actions runs, Agents sessions, or Dependabot in a repository - environment: available to GitHub Actions runs for a deployment environment in a repository - - organization: available to GitHub Actions runs, Dependabot, or Codespaces within an organization + - organization: available to GitHub Actions runs, Agents sessions, Dependabot, or Codespaces within an organization - user: available to Codespaces for your user `), Args: cobra.ExactArgs(1), @@ -81,7 +81,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Delete a secret for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Delete a secret for an environment") cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Delete a secret for your user") - cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "Delete a secret for a specific application") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Agents, shared.Codespaces, shared.Dependabot}, "Delete a secret for a specific application") return cmd } diff --git a/pkg/cmd/secret/delete/delete_test.go b/pkg/cmd/secret/delete/delete_test.go index 48200b8813b..570df4615d5 100644 --- a/pkg/cmd/secret/delete/delete_test.go +++ b/pkg/cmd/secret/delete/delete_test.go @@ -89,6 +89,23 @@ func TestNewCmdDelete(t *testing.T) { Application: "Codespaces", }, }, + { + name: "Agents org", + cli: "cool --app agents --org UmbrellaCorporation", + wants: DeleteOptions{ + SecretName: "cool", + OrgName: "UmbrellaCorporation", + Application: "Agents", + }, + }, + { + name: "Agents repo", + cli: "cool --app Agents", + wants: DeleteOptions{ + SecretName: "cool", + Application: "Agents", + }, + }, } for _, tt := range tests { @@ -311,6 +328,17 @@ func Test_removeRun_repo(t *testing.T) { reg.Register(httpmock.WithHost(httpmock.REST("DELETE", "api/v3/repos/owner/repo/dependabot/secrets/cool_dependabot_secret"), "example.com"), httpmock.StatusStringResponse(204, "No Content")) }, }, + { + name: "Agents", + opts: &DeleteOptions{ + Application: "agents", + SecretName: "cool_agents_secret", + }, + host: "github.com", + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.WithHost(httpmock.REST("DELETE", "repos/owner/repo/agents/secrets/cool_agents_secret"), "api.github.com"), httpmock.StatusStringResponse(204, "No Content")) + }, + }, { name: "defaults to Actions", opts: &DeleteOptions{ @@ -433,6 +461,14 @@ func Test_removeRun_org(t *testing.T) { }, wantPath: "orgs/UmbrellaCorporation/codespaces/secrets/tVirus", }, + { + name: "Agents org", + opts: &DeleteOptions{ + Application: "agents", + OrgName: "UmbrellaCorporation", + }, + wantPath: "orgs/UmbrellaCorporation/agents/secrets/tVirus", + }, } for _, tt := range tests { diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index 06476a86d49..66334ea9152 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -60,9 +60,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List secrets", Long: heredoc.Doc(` List secrets on one of the following levels: - - repository (default): available to GitHub Actions runs or Dependabot in a repository + - repository (default): available to GitHub Actions runs, Agents sessions, or Dependabot in a repository - environment: available to GitHub Actions runs for a deployment environment in a repository - - organization: available to GitHub Actions runs, Dependabot, or Codespaces within an organization + - organization: available to GitHub Actions runs, Agents sessions, Dependabot, or Codespaces within an organization - user: available to Codespaces for your user `), Aliases: []string{"ls"}, @@ -98,7 +98,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List secrets for an environment") cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "List a secret for your user") - cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "List secrets for a specific application") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Agents, shared.Codespaces, shared.Dependabot}, "List secrets for a specific application") cmdutil.AddJSONFlags(cmd, &opts.Exporter, secretFields) return cmd } diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 5c4dd4874fa..da7cb892356 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -74,6 +74,21 @@ func Test_NewCmdList(t *testing.T) { OrgName: "UmbrellaCorporation", }, }, + { + name: "Agents repo", + cli: "--app Agents", + wants: ListOptions{ + Application: "Agents", + }, + }, + { + name: "Agents org", + cli: "--app Agents --org UmbrellaCorporation", + wants: ListOptions{ + Application: "Agents", + OrgName: "UmbrellaCorporation", + }, + }, } for _, tt := range tests { @@ -443,6 +458,58 @@ func Test_listRun(t *testing.T) { "SECRET_THREE\t1975-11-30T00:00:00Z\tSELECTED", }, }, + { + name: "Agents repo tty", + tty: true, + opts: &ListOptions{ + Application: "Agents", + }, + wantOut: []string{ + "NAME UPDATED", + "SECRET_ONE about 34 years ago", + "SECRET_TWO about 2 years ago", + "SECRET_THREE about 47 years ago", + }, + }, + { + name: "Agents repo not tty", + tty: false, + opts: &ListOptions{ + Application: "Agents", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11T00:00:00Z", + "SECRET_TWO\t2020-12-04T00:00:00Z", + "SECRET_THREE\t1975-11-30T00:00:00Z", + }, + }, + { + name: "Agents org tty", + tty: true, + opts: &ListOptions{ + Application: "Agents", + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "NAME UPDATED VISIBILITY", + "SECRET_ONE about 34 years ago Visible to all repositories", + "SECRET_TWO about 2 years ago Visible to private repositories", + "SECRET_THREE about 47 years ago Visible to 2 selected repositories", + }, + }, + { + name: "Agents org not tty", + tty: false, + opts: &ListOptions{ + Application: "Agents", + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11T00:00:00Z\tALL", + "SECRET_TWO\t2020-12-04T00:00:00Z\tPRIVATE", + "SECRET_THREE\t1975-11-30T00:00:00Z\tSELECTED", + }, + }, } for _, tt := range tests { @@ -542,6 +609,8 @@ func Test_listRun(t *testing.T) { if tt.opts.Application == "Dependabot" { path = strings.Replace(path, "actions", "dependabot", 1) + } else if tt.opts.Application == "Agents" { + path = strings.Replace(path, "actions", "agents", 1) } reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go index 6c08e1d2405..32d974fafa6 100644 --- a/pkg/cmd/secret/secret.go +++ b/pkg/cmd/secret/secret.go @@ -15,7 +15,7 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { Short: "Manage GitHub secrets", Long: heredoc.Docf(` Secrets can be set at the repository, or organization level for use in - GitHub Actions or Dependabot. User, organization, and repository secrets can be set for + GitHub Actions, Agents, or Dependabot. User, organization, and repository secrets can be set for use in GitHub Codespaces. Environment secrets can be set for use in GitHub Actions. Run %[1]sgh help secret set%[1]s to learn how to get started. `, "`"), diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 40f2fac0d0f..93cc14f219b 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -63,9 +63,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command Short: "Create or update secrets", Long: heredoc.Doc(` Set a value for a secret on one of the following levels: - - repository (default): available to GitHub Actions runs or Dependabot in a repository + - repository (default): available to GitHub Actions runs, Agents sessions, or Dependabot in a repository - environment: available to GitHub Actions runs for a deployment environment in a repository - - organization: available to GitHub Actions runs, Dependabot, or Codespaces within an organization + - organization: available to GitHub Actions runs, Agents sessions, Dependabot, or Codespaces within an organization - user: available to Codespaces for your user Organization and user secrets can optionally be restricted to only be available to @@ -195,7 +195,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "The value for the secret (reads from standard input if not specified)") cmd.Flags().BoolVar(&opts.DoNotStore, "no-store", false, "Print the encrypted, base64-encoded value instead of storing it on GitHub") cmd.Flags().StringVarP(&opts.EnvFile, "env-file", "f", "", "Load secret names and values from a dotenv-formatted `file`") - cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "Set the application for a secret") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Agents, shared.Codespaces, shared.Dependabot}, "Set the application for a secret") return cmd } diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 38c0fb5a9cf..237bc70e1dc 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -213,6 +213,29 @@ func TestNewCmdSet(t *testing.T) { Application: "Codespaces", }, }, + { + name: "Agents org", + args: `random_secret --org coolOrg --body "random value" --visibility selected --repos "coolRepo,cli/cli" --app Agents`, + wants: SetOptions{ + SecretName: "random_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"coolRepo", "cli/cli"}, + Body: "random value", + OrgName: "coolOrg", + Application: "Agents", + }, + }, + { + name: "Agents repo", + args: `cool_secret --body "a secret" --app Agents`, + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Private, + Body: "a secret", + OrgName: "", + Application: "Agents", + }, + }, } for _, tt := range tests { @@ -407,6 +430,13 @@ func Test_setRun_repo(t *testing.T) { }, wantApp: "actions", }, + { + name: "Agents", + opts: &SetOptions{ + Application: "agents", + }, + wantApp: "agents", + }, { name: "Dependabot", opts: &SetOptions{ @@ -573,6 +603,37 @@ func Test_setRun_org(t *testing.T) { wantRepositories: []int64{}, wantApp: "dependabot", }, + { + name: "Agents", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.All, + Application: shared.Agents, + }, + wantApp: "agents", + }, + { + name: "Agents selected visibility", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.Selected, + Application: shared.Agents, + RepositoryNames: []string{"birkin", "UmbrellaCorporation/wesker"}, + }, + wantRepositories: []int64{1, 2}, + wantApp: "agents", + }, + { + name: "Agents no repos visibility", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.Selected, + Application: shared.Agents, + RepositoryNames: []string{}, + }, + wantRepositories: []int64{}, + wantApp: "agents", + }, } for _, tt := range tests { diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go index 9fe6874164b..ddf4e67b574 100644 --- a/pkg/cmd/secret/shared/shared.go +++ b/pkg/cmd/secret/shared/shared.go @@ -20,6 +20,7 @@ type App string const ( Actions = "actions" + Agents = "agents" Codespaces = "codespaces" Dependabot = "dependabot" Unknown = "unknown" @@ -66,6 +67,8 @@ func GetSecretApp(app string, entity SecretEntity) (App, error) { switch strings.ToLower(app) { case Actions: return Actions, nil + case Agents: + return Agents, nil case Codespaces: return Codespaces, nil case Dependabot: @@ -84,6 +87,8 @@ func IsSupportedSecretEntity(app App, entity SecretEntity) bool { switch app { case Actions: return entity == Repository || entity == Organization || entity == Environment + case Agents: + return entity == Repository || entity == Organization case Codespaces: return entity == User || entity == Organization || entity == Repository case Dependabot: diff --git a/pkg/cmd/secret/shared/shared_test.go b/pkg/cmd/secret/shared/shared_test.go index eb121f0a853..91675c44c59 100644 --- a/pkg/cmd/secret/shared/shared_test.go +++ b/pkg/cmd/secret/shared/shared_test.go @@ -81,6 +81,11 @@ func TestGetSecretApp(t *testing.T) { app: "actions", want: Actions, }, + { + name: "Agents", + app: "agents", + want: Agents, + }, { name: "Codespaces", app: "codespaces", @@ -161,6 +166,19 @@ func TestIsSupportedSecretEntity(t *testing.T) { Unknown, }, }, + { + name: "Agents", + app: Agents, + supportedEntities: []SecretEntity{ + Repository, + Organization, + }, + unsupportedEntities: []SecretEntity{ + Environment, + User, + Unknown, + }, + }, { name: "Codespaces", app: Codespaces, diff --git a/pkg/cmd/send-telemetry/send_telemetry.go b/pkg/cmd/send-telemetry/send_telemetry.go new file mode 100644 index 00000000000..fce6dff8391 --- /dev/null +++ b/pkg/cmd/send-telemetry/send_telemetry.go @@ -0,0 +1,135 @@ +package sendtelemetry + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "time" + + "github.com/cli/cli/v2/internal/barista/observability" + "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +const defaultTelemetryEndpointURL = "https://cafe.github.com" + +type SendTelemetryOptions struct { + TelemetryEndpointURL string + PayloadJSON string + HTTPUnixSocket string +} + +func NewCmdSendTelemetry(f *cmdutil.Factory) *cobra.Command { + return newCmdSendTelemetry(f, nil) +} + +func newCmdSendTelemetry(f *cmdutil.Factory, runF func(*SendTelemetryOptions) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "send-telemetry", + Short: "Send telemetry event to GitHub", + Hidden: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + payloadJSON, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("reading payload from stdin: %w", err) + } + if len(payloadJSON) == 0 { + return fmt.Errorf("no payload provided on stdin") + } + + opts := &SendTelemetryOptions{ + TelemetryEndpointURL: cmp.Or(os.Getenv("GH_TELEMETRY_ENDPOINT_URL"), defaultTelemetryEndpointURL), + PayloadJSON: string(payloadJSON), + // This is a best effort to use a Unix Socket if configured. In most cases, if there is one configured + // it will be at the global level. However, since the telemetry service is not related to a specific host, we can't + // know that the socket we choose will work. + HTTPUnixSocket: cfg.HTTPUnixSocket("").Value, + } + + if runF != nil { + return runF(opts) + } + + return runSendTelemetry(cmd.Context(), opts) + }, + } + + cmdutil.DisableAuthCheck(cmd) + cmdutil.DisableTelemetry(cmd) + + return cmd +} + +func runSendTelemetry(ctx context.Context, opts *SendTelemetryOptions) error { + httpClient := &http.Client{ + Timeout: 2 * time.Second, + Transport: &userAgentTransport{ + base: handleUnixDomainSocket(opts.HTTPUnixSocket), + userAgent: fmt.Sprintf("GitHub CLI %s", build.Version), + }, + } + + client := observability.NewTelemetryAPIProtobufClient(opts.TelemetryEndpointURL, httpClient) + + var payload telemetry.SendTelemetryPayload + if err := json.Unmarshal([]byte(opts.PayloadJSON), &payload); err != nil { + return fmt.Errorf("parsing payload JSON: %w", err) + } + + if len(payload.Events) == 0 { + return nil + } + + events := make([]*observability.TelemetryEvent, len(payload.Events)) + for i, event := range payload.Events { + events[i] = &observability.TelemetryEvent{ + App: "github-cli", + EventType: event.Type, + Dimensions: event.Dimensions, + Measures: event.Measures, + } + } + + _, err := client.RecordEvents(ctx, &observability.RecordEventsRequest{ + Events: events, + }) + return err +} + +type userAgentTransport struct { + base http.RoundTripper + userAgent string +} + +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", t.userAgent) + return t.base.RoundTrip(req) +} + +func handleUnixDomainSocket(socketPath string) http.RoundTripper { + if socketPath == "" { + return http.DefaultTransport + } + + dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + } + + return &http.Transport{ + DialContext: dialContext, + DisableKeepAlives: true, + } +} diff --git a/pkg/cmd/send-telemetry/send_telemetry_test.go b/pkg/cmd/send-telemetry/send_telemetry_test.go new file mode 100644 index 00000000000..8ec2f83c555 --- /dev/null +++ b/pkg/cmd/send-telemetry/send_telemetry_test.go @@ -0,0 +1,226 @@ +package sendtelemetry + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/cli/cli/v2/internal/barista/observability" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockTelemetryAPI struct { + request *observability.RecordEventsRequest + err error +} + +func (m *mockTelemetryAPI) RecordEvents(_ context.Context, req *observability.RecordEventsRequest) (*observability.RecordEventsResponse, error) { + m.request = req + return &observability.RecordEventsResponse{}, m.err +} + +func TestNewCmdSendTelemetry(t *testing.T) { + tests := []struct { + name string + stdin string + env map[string]string + wantOpts SendTelemetryOptions + wantErr string + }{ + { + name: "reads payload from stdin", + stdin: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: defaultTelemetryEndpointURL, + PayloadJSON: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`, + }, + }, + { + name: "uses GH_TELEMETRY_ENDPOINT_URL env var", + stdin: `{"events":[]}`, + env: map[string]string{"GH_TELEMETRY_ENDPOINT_URL": "https://custom.endpoint"}, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: "https://custom.endpoint", + PayloadJSON: `{"events":[]}`, + }, + }, + { + name: "defaults endpoint when env var not set", + stdin: `{}`, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: defaultTelemetryEndpointURL, + PayloadJSON: `{}`, + }, + }, + { + name: "errors on empty stdin", + stdin: "", + wantErr: "no payload provided on stdin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + } + + var gotOpts *SendTelemetryOptions + cmd := newCmdSendTelemetry(f, func(opts *SendTelemetryOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{}) + cmd.SetIn(strings.NewReader(tt.stdin)) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantOpts.TelemetryEndpointURL, gotOpts.TelemetryEndpointURL) + assert.Equal(t, tt.wantOpts.PayloadJSON, gotOpts.PayloadJSON) + }) + } +} + +func TestRunSendTelemetry(t *testing.T) { + tests := []struct { + name string + payload telemetry.SendTelemetryPayload + serverErr error + wantErr bool + assertFunc func(t *testing.T, req *observability.RecordEventsRequest) + }{ + { + name: "posts single event to endpoint", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{ + { + Type: "command_invocation", + Dimensions: map[string]string{ + "command": "gh pr create", + "device_id": "abc123", + "os": "darwin", + }, + Measures: map[string]int64{"duration_ms": 150}, + }, + }, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + require.Len(t, req.Events, 1) + event := req.Events[0] + assert.Equal(t, "github-cli", event.App) + assert.Equal(t, "command_invocation", event.EventType) + assert.Equal(t, "gh pr create", event.Dimensions["command"]) + assert.Equal(t, "abc123", event.Dimensions["device_id"]) + assert.Equal(t, "darwin", event.Dimensions["os"]) + }, + }, + { + name: "posts multiple events in single batch request", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{ + {Type: "event1", Dimensions: map[string]string{"a": "1"}}, + {Type: "event2", Dimensions: map[string]string{"b": "2"}}, + }, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + require.Len(t, req.Events, 2) + assert.Equal(t, "1", req.Events[0].Dimensions["a"]) + assert.Equal(t, "2", req.Events[1].Dimensions["b"]) + assert.Equal(t, "github-cli", req.Events[0].App) + assert.Equal(t, "event1", req.Events[0].EventType) + assert.Equal(t, "github-cli", req.Events[1].App) + assert.Equal(t, "event2", req.Events[1].EventType) + }, + }, + { + name: "empty events list produces no request", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{}, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + assert.Nil(t, req) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockTelemetryAPI{err: tt.serverErr} + handler := observability.NewTelemetryAPIServer(mock) + server := httptest.NewServer(handler) + defer server.Close() + + opts := &SendTelemetryOptions{ + TelemetryEndpointURL: server.URL, + PayloadJSON: mustMarshal(t, tt.payload), + } + + err := runSendTelemetry(context.Background(), opts) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + if tt.assertFunc != nil { + tt.assertFunc(t, mock.request) + } + }) + } +} + +func TestRunSendTelemetryInvalidPayload(t *testing.T) { + err := runSendTelemetry(context.Background(), &SendTelemetryOptions{ + TelemetryEndpointURL: "http://localhost:0", + PayloadJSON: "not-json", + }) + require.Error(t, err) +} + +func TestRunSendTelemetryServerError(t *testing.T) { + mock := &mockTelemetryAPI{err: assert.AnError} + handler := observability.NewTelemetryAPIServer(mock) + server := httptest.NewServer(handler) + defer server.Close() + + err := runSendTelemetry(context.Background(), &SendTelemetryOptions{ + TelemetryEndpointURL: server.URL, + PayloadJSON: `{"events":[{"type":"test","dimensions":{"a":"1"}}]}`, + }) + require.Error(t, err) +} + +func mustMarshal(t *testing.T, v any) string { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + return string(data) +} diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go new file mode 100644 index 00000000000..b944c4c55b7 --- /dev/null +++ b/pkg/cmd/skills/install/install.go @@ -0,0 +1,1301 @@ +package install + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + // allSkillsKey is the persistent option label for selecting all skills. + allSkillsKey = "(all skills)" + + // maxSearchResults caps how many skills are shown per search page in + // interactive selection, keeping the prompt readable. + maxSearchResults = 30 +) + +// InstallOptions holds all dependencies and user-provided flags for the install command. +type InstallOptions struct { + IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + GitClient *git.Client + Remotes func() (ghContext.Remotes, error) + + SkillSource string // owner/repo or local path (when --from-local is set) + SkillName string // possibly with @version suffix + Agent string + Scope string + ScopeChanged bool // true when --scope was explicitly set + Pin string + Dir string // overrides --agent and --scope + All bool + Force bool + FromLocal bool // treat SkillSource as a local directory path + AllowHiddenDirs bool // include skills in dot-prefixed directories + Upstream bool // install from upstream when re-published skill detected + + repo ghrepo.Interface // set when SkillSource is a GitHub repository + localPath string // set when FromLocal is true + version string // parsed from SkillName@version +} + +// NewCmdInstall creates the "skills install" command. +func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*InstallOptions) error) *cobra.Command { + opts := &InstallOptions{ + IO: f.IOStreams, + Telemetry: telemetry, + Prompter: f.Prompter, + GitClient: f.GitClient, + Remotes: f.Remotes, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "install [] [flags]", + Short: "Install agent skills from a GitHub repository (preview)", + Long: heredoc.Docf(` + Install agent skills from a GitHub repository or local directory into + your local environment. Skills are placed in a host-specific directory + at either project scope (inside the current git repository) or user + scope (in your home directory, available everywhere). + + A wide range of AI coding agents are supported, including GitHub + Copilot, Claude Code, Cursor, Codex, Gemini CLI, Antigravity, Amp, + Goose, Junie, OpenCode, Windsurf, and many more. + + Supported %[1]s--agent%[1]s values: + + %[2]s + + Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a + custom directory. The default scope is %[1]sproject%[1]s, and the default + agent is %[1]sgithub-copilot%[1]s (when running non-interactively). + + At project scope, several agents (including GitHub Copilot, Cursor, + Codex, Gemini CLI, Antigravity, Amp, Cline, OpenCode, and Warp) share + the %[1]s.agents/skills%[1]s directory. If you select multiple hosts that + resolve to the same destination, each skill is installed there only once. + + The first argument is a GitHub repository in %[1]sOWNER/REPO%[1]s format. + Use %[1]s--from-local%[1]s to install from a local directory instead. + Local skills are auto-discovered using the same conventions as remote + repositories, and files are copied (not symlinked) with local-path + tracking metadata injected into frontmatter. + + Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention + defined by the Agent Skills specification, including when the %[1]sskills/%[1]s + directory is nested under a prefix (e.g. %[1]sterraform/code-generation/skills/...%[1]s). + For more information on the specification, + see: https://agentskills.io/specification + + The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), + or an exact path within the repository (%[1]sskills/author/skill%[1]s, + %[1]spackages/agent-skills/code-review%[1]s, or any %[1]s.../SKILL.md%[1]s path). + Namespaced names with one slash are matched by name. Use a %[1]sSKILL.md%[1]s + suffix to force a one-directory path outside the standard conventions. + + Performance tip: when installing from a large repository with many + skills, providing an exact path instead of a skill name avoids a + full tree traversal of the repository, making the install significantly faster. + + When a skill name is provided without a version, the CLI resolves the + version in this order: + + 1. Latest tagged release in the repository + 2. Default branch HEAD + + To pin to a specific version, either append %[1]s@VERSION%[1]s to the skill + name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA. + + Installed skills have source tracking metadata injected into their + frontmatter. This metadata identifies the source repository and + enables %[1]sgh skill update%[1]s to detect changes. + + Use %[1]s--all%[1]s to install every discovered skill from the repository + without prompting for skill selection. When run non-interactively, %[1]srepository%[1]s and either + a skill name or %[1]s--all%[1]s are required. + `, "`", registry.AgentHelpList()), + Example: heredoc.Doc(` + # Interactive: choose repo, skill, and agent + $ gh skill install + + # Choose a skill from the repo interactively + $ gh skill install github/awesome-copilot + + # Install a specific skill + $ gh skill install github/awesome-copilot git-commit + + # Install all skills from a repository + $ gh skill install github/awesome-copilot --all + + # Install a specific version + $ gh skill install github/awesome-copilot git-commit@v1.2.0 + + # Install from a large namespaced repo by path (efficient, skips full discovery) + $ gh skill install github/awesome-copilot skills/monalisa/code-review + + # Install from a non-standard nested path (efficient, skips full discovery) + $ gh skill install monalisa/skills-repo packages/agent-skills/code-review + + # Install from a local directory + $ gh skill install ./my-skills-repo --from-local + + # Install a specific local skill + $ gh skill install ./my-skills-repo git-commit --from-local + + # Install for Claude Code at user scope + $ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user + + # Pin to a specific git ref + $ gh skill install github/awesome-copilot git-commit --pin v2.0.0 + + # Install skills from hidden directories (e.g. .claude/skills/) + $ gh skill install owner/repo --allow-hidden-dirs + `), + Aliases: []string{"add"}, + Args: cobra.MaximumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) >= 1 { + opts.SkillSource = args[0] + } + if len(args) >= 2 { + opts.SkillName = args[1] + } + opts.ScopeChanged = cmd.Flags().Changed("scope") + + if opts.All && opts.SkillName != "" { + return cmdutil.FlagErrorf("cannot use --all with a skill argument") + } + + // Resolve the source type early so installRun can branch directly. + if opts.FromLocal { + if opts.SkillSource == "" { + return cmdutil.FlagErrorf("--from-local requires a directory path argument") + } + opts.localPath = opts.SkillSource + } else if len(args) == 0 && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("must specify a repository to install from") + } + + if err := cmdutil.MutuallyExclusive("--from-local and --pin cannot be used together", opts.FromLocal, opts.Pin != ""); err != nil { + return err + } + + if err := cmdutil.MutuallyExclusive("--from-local and --upstream cannot be used together", opts.FromLocal, opts.Upstream); err != nil { + return err + } + + if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") { + return cmdutil.FlagErrorf("cannot use --pin with an inline @version in the skill name") + } + + if runF != nil { + return runF(opts) + } + return installRun(opts) + }, + } + + agentFlag := cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent") + agentFlag.Usage = "Target agent (see supported values above)" + cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") + cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") + cmd.Flags().BoolVar(&opts.All, "all", false, "Install all skills without prompting for skill selection") + cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") + cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") + cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") + cmd.Flags().BoolVar(&opts.Upstream, "upstream", false, "Install from the upstream source when a re-published skill is detected") + cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) + + return cmd +} + +func installRun(opts *InstallOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + if opts.localPath != "" { + return runLocalInstall(opts) + } + + repo, repoSource, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter) + if err != nil { + return err + } + opts.repo = repo + opts.SkillSource = repoSource + + parseSkillFromOpts(opts) + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + hostname := opts.repo.RepoHost() + if err := source.ValidateSupportedHost(hostname); err != nil { + return err + } + + // Kick off the visibility fetch in parallel with the install work so + // the extra API roundtrip doesn't add latency on the critical path. + // The result is consumed when the telemetry event is emitted below. + // Capture repo fields now to avoid a data race if opts.repo is + // swapped during an upstream redirect. + type visResult struct { + vis discovery.RepoVisibility + err error + } + visCh := make(chan visResult, 1) + visOwner := opts.repo.RepoOwner() + visRepo := opts.repo.RepoName() + go func() { + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, visOwner, visRepo) + visCh <- visResult{vis: vis, err: err} + }() + + resolved, err := resolveVersion(opts, apiClient, hostname) + if err != nil { + return err + } + + var selectedSkills []discovery.Skill + + if discovery.IsSkillPath(opts.SkillName) { + opts.IO.StartProgressIndicatorWithLabel("Looking up skill") + skill, err := discovery.DiscoverSkillByPath(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + selectedSkills = []discovery.Skill{*skill} + } else { + skills, err := discoverSkills(opts, apiClient, hostname, resolved) + if err != nil { + return err + } + + selectedSkills, err = selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{ + matchByName: matchSkillByName, + sourceHint: ghrepo.FullName(opts.repo), + fetchDescriptions: func() { + opts.IO.StartProgressIndicatorWithLabel("Fetching skill info") + discovery.FetchDescriptionsConcurrent(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), skills, nil) + opts.IO.StopProgressIndicator() + }, + }) + if err != nil { + return err + } + } + + // Track upstream provenance detection result for telemetry. + upstreamSource := "none" + + // Check if the selected skill was re-published from an upstream source. + // The re-publisher's SKILL.md will have github-repo metadata pointing + // to the original source repo. If detected, offer to install directly + // from upstream instead. + if len(selectedSkills) == 1 && selectedSkills[0].BlobSHA != "" { + upstreamRepo, detected, err := checkUpstreamProvenance(opts, apiClient, hostname, selectedSkills[0], resolved.SHA) + if err != nil { + return err + } + if upstreamRepo != nil { + redirectDims := map[string]string{} + select { + case r := <-visCh: + if r.err == nil && r.vis == discovery.RepoVisibilityPublic { + redirectDims["from_owner"] = visOwner + redirectDims["from_repo"] = visRepo + } + case <-time.After(visibilityWaitTimeout): + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_upstream_redirect", + Dimensions: redirectDims, + }) + opts.repo = upstreamRepo + opts.SkillSource = ghrepo.FullName(upstreamRepo) + opts.version = "" + opts.Pin = "" + return installRun(opts) + } + if detected { + upstreamSource = "republisher" + } + } + + printPreInstallDisclaimer(opts.IO.ErrOut, cs) + + selectedHosts, err := resolveHosts(opts, canPrompt) + if err != nil { + return err + } + + scope, err := resolveScope(opts, canPrompt) + if err != nil { + return err + } + + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() + repoSource = ghrepo.FullName(opts.repo) + + plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err + } + + for _, plan := range plans { + if len(plans) > 1 { + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) + } + + result, err := installer.Install(&installer.Options{ + Host: hostname, + Owner: opts.repo.RepoOwner(), + Repo: opts.repo.RepoName(), + Ref: resolved.Ref, + SHA: resolved.SHA, + PinnedRef: opts.Pin, + Skills: plan.skills, + Dir: plan.dir, + Client: apiClient, + OnProgress: installProgress(opts.IO, len(plan.skills)), + }) + + if result != nil { + for _, w := range result.Warnings { + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.WarningIcon(), w) + } + + for _, name := range result.Installed { + fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n", + cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir)) + } + + printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed, opts.AllowHiddenDirs) + printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) + } + + if err != nil { + return err + } + } + + dims := map[string]string{ + "agent_hosts": mapAgentHostsToIDs(selectedHosts), + "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + "upstream_source": upstreamSource, + } + select { + case r := <-visCh: + if r.err == nil { + dims["repo_visibility"] = string(r.vis) + if r.vis == discovery.RepoVisibilityPublic { + dims["skill_owner"] = opts.repo.RepoOwner() + dims["skill_repo"] = opts.repo.RepoName() + dims["skill_names"] = mapSkillsToNames(selectedSkills) + } + } else { + dims["repo_visibility"] = "unknown" + } + case <-time.After(visibilityWaitTimeout): + dims["repo_visibility"] = "unknown" + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_install", + Dimensions: dims, + }) + + return nil +} + +// visibilityWaitTimeout is how long to wait at telemetry-emit time for +// the in-flight repo visibility fetch before giving up and emitting +// repo_visibility="unknown". By this point the command has already done +// several serial API calls and (for install) a git sparse-checkout, so +// the fetch has almost always completed; this budget is a short safety +// net for the case where that single REST call has stalled. +const visibilityWaitTimeout = 200 * time.Millisecond + +func mapSkillsToNames(skills []discovery.Skill) string { + names := make([]string, len(skills)) + for i, s := range skills { + names[i] = s.DisplayName() + } + return strings.Join(names, ",") +} + +func mapAgentHostsToIDs(hosts []*registry.AgentHost) string { + agentHostIDs := make([]string, len(hosts)) + for i, h := range hosts { + agentHostIDs[i] = h.ID + } + return strings.Join(agentHostIDs, ",") +} + +// runLocalInstall handles installation from a local directory path. +func runLocalInstall(opts *InstallOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + sourcePath := opts.localPath + if sourcePath == "~" { + if home, err := os.UserHomeDir(); err == nil { + sourcePath = home + } + } else if after, ok := strings.CutPrefix(sourcePath, "~/"); ok { + if home, err := os.UserHomeDir(); err == nil { + sourcePath = filepath.Join(home, after) + } + } + + absSource, err := filepath.Abs(sourcePath) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + allSkills, err := discovery.DiscoverLocalSkillsWithOptions(absSource, discovery.DiscoverOptions{}) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + skills, err := filterHiddenDirSkills(opts, allSkills) + if err != nil { + return err + } + + if canPrompt { + fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills)) + } + + selectedSkills, err := selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{ + matchByName: matchLocalSkillByName, + sourceHint: absSource, + }) + if err != nil { + return err + } + + printPreInstallDisclaimer(opts.IO.ErrOut, cs) + + selectedHosts, err := resolveHosts(opts, canPrompt) + if err != nil { + return err + } + + scope, err := resolveScope(opts, canPrompt) + if err != nil { + return err + } + + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() + + plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err + } + + for _, plan := range plans { + if len(plans) > 1 { + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) + } + + result, err := installer.InstallLocal(&installer.LocalOptions{ + SourceDir: absSource, + Skills: plan.skills, + Dir: plan.dir, + }) + if err != nil { + return err + } + + for _, name := range result.Installed { + fmt.Fprintf(opts.IO.Out, "Installed %s (from %s) in %s\n", + name, opts.SkillSource, friendlyDir(result.Dir)) + } + + printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed, false) + printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) + } + + return nil +} + +func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (ghrepo.Interface, string, error) { + if skillSource == "" { + if !canPrompt { + return nil, "", cmdutil.FlagErrorf("must specify a repository to install from") + } + repoInput, err := p.Input("Repository (owner/repo):", "") + if err != nil { + return nil, "", err + } + skillSource = strings.TrimSpace(repoInput) + if skillSource == "" { + return nil, "", fmt.Errorf("must specify a repository to install from") + } + } + repo, err := ghrepo.FromFullName(skillSource) + if err != nil { + return nil, "", cmdutil.FlagErrorf("invalid repository reference %q: expected OWNER/REPO, HOST/OWNER/REPO, or a full URL", skillSource) + } + return repo, skillSource, nil +} + +func parseSkillFromOpts(opts *InstallOptions) { + if opts.SkillName != "" { + if name, version, ok := cutLast(opts.SkillName, "@"); ok && name != "" { + opts.version = version + opts.SkillName = name + return + } + } + if opts.Pin != "" { + opts.version = opts.Pin + } +} + +// cutLast splits s around the last occurrence of sep, +// returning the text before and after sep, and whether sep was found. +func cutLast(s, sep string) (before, after string, found bool) { + if i := strings.LastIndex(s, sep); i >= 0 { + return s[:i], s[i+len(sep):], true + } + return s, "", false +} + +func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { + opts.IO.StartProgressIndicatorWithLabel("Resolving version") + resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) + opts.IO.StopProgressIndicator() + if err != nil { + return nil, fmt.Errorf("could not resolve version: %w", err) + } + fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", discovery.ShortRef(resolved.Ref), git.ShortSHA(resolved.SHA)) + return resolved, nil +} + +func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + allSkills, err := discovery.DiscoverSkillsWithOptions(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, discovery.DiscoverOptions{}) + opts.IO.StopProgressIndicator() + if err != nil { + var treeTooLarge *discovery.TreeTooLargeError + if errors.As(err, &treeTooLarge) { + fmt.Fprintf(opts.IO.ErrOut, "%s\n Use path-based install instead: gh skill install %s/%s skills/\n", + err, treeTooLarge.Owner, treeTooLarge.Repo) + return nil, err + } + return nil, err + } + skills, filterErr := filterHiddenDirSkills(opts, allSkills) + if filterErr != nil { + return nil, filterErr + } + logConventions(opts.IO, skills) + for _, s := range skills { + if !discovery.IsSpecCompliant(s.Name) { + fmt.Fprintf(opts.IO.ErrOut, "Warning: skill %q does not follow the agentskills.io naming convention\n", s.DisplayName()) + } + } + return skills, nil +} + +func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { + conventions := make(map[string]int) + for _, s := range skills { + conventions[s.Convention]++ + } + if n, ok := conventions["skills-namespaced"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n) + } + if n, ok := conventions["plugins"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the plugins/ convention\n", n) + } + if n, ok := conventions["root"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n) + } +} + +// skillSelector holds the callbacks that differ between remote and local skill selection. +type skillSelector struct { + // matchByName resolves a skill name to matching skills. + matchByName func(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) + // sourceHint is shown in collision error guidance (e.g. "owner/repo" or "/path/to/skills"). + sourceHint string + // fetchDescriptions, if non-nil, is called before prompting to pre-populate descriptions. + fetchDescriptions func() +} + +type installPlan struct { + dir string + hosts []*registry.AgentHost + skills []discovery.Skill +} + +func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { + checkCollisions := func(ss []discovery.Skill) error { + if err := collisionError(ss); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "Hint: install individually using the full name: gh skill install %s namespace/skill-name\n", sel.sourceHint) + return err + } + return nil + } + + if opts.All { + if err := checkCollisions(skills); err != nil { + return nil, err + } + return skills, nil + } + + if opts.SkillName != "" { + return sel.matchByName(opts, skills) + } + + if !canPrompt { + return nil, cmdutil.FlagErrorf("must specify a skill name when not running interactively") + } + + if sel.fetchDescriptions != nil { + sel.fetchDescriptions() + } + + tw := opts.IO.TerminalWidth() + descWidth := tw - 35 + if descWidth < 20 { + descWidth = 20 + } + + selected, err := opts.Prompter.MultiSelectWithSearch( + "Select skill(s) to install:", + "Filter skills", + nil, + []string{allSkillsKey}, + skillSearchFunc(skills, descWidth), + ) + if err != nil { + return nil, err + } + + if len(selected) == 0 { + return nil, fmt.Errorf("must select at least one skill") + } + + for _, s := range selected { + if s == allSkillsKey { + if err := checkCollisions(skills); err != nil { + return nil, err + } + return skills, nil + } + } + + result, err := matchSelectedSkills(skills, selected) + if err != nil { + return nil, err + } + return result, checkCollisions(result) +} + +func matchSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) { + for _, s := range skills { + if s.DisplayName() == opts.SkillName { + return []discovery.Skill{s}, nil + } + } + + var matches []discovery.Skill + for _, s := range skills { + if s.Name == opts.SkillName { + matches = append(matches, s) + } + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo)) + case 1: + return matches, nil + default: + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.DisplayName() + } + return nil, fmt.Errorf( + "skill name %q is ambiguous, multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate", + opts.SkillName, strings.Join(names, "\n "), names[0], + ) + } +} + +func matchLocalSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) { + for _, s := range skills { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return []discovery.Skill{s}, nil + } + } + return nil, fmt.Errorf("skill %q not found in local directory", opts.SkillName) +} + +// skillSearchFunc returns a search function for MultiSelectWithSearch that +// filters skills by case-insensitive substring match on name and description. +func skillSearchFunc(skills []discovery.Skill, descWidth int) func(string) prompter.MultiSelectSearchResult { + return func(query string) prompter.MultiSelectSearchResult { + var matched []discovery.Skill + if query == "" { + matched = skills + } else { + q := strings.ToLower(query) + for _, s := range skills { + if strings.Contains(strings.ToLower(s.DisplayName()), q) || + strings.Contains(strings.ToLower(s.Description), q) { + matched = append(matched, s) + } + } + } + + more := 0 + if len(matched) > maxSearchResults { + more = len(matched) - maxSearchResults + matched = matched[:maxSearchResults] + } + + keys := make([]string, len(matched)) + labels := make([]string, len(matched)) + for i, s := range matched { + keys[i] = s.DisplayName() + if s.Description != "" { + labels[i] = fmt.Sprintf("%s - %s", s.DisplayName(), truncateDescription(s.Description, descWidth)) + } else { + labels[i] = s.DisplayName() + } + } + + return prompter.MultiSelectSearchResult{ + Keys: keys, + Labels: labels, + MoreResults: more, + } + } +} + +// matchSelectedSkills maps display names back to skill structs. +func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discovery.Skill, error) { + nameSet := make(map[string]struct{}, len(selected)) + for _, name := range selected { + nameSet[name] = struct{}{} + } + + var result []discovery.Skill + for _, s := range skills { + if _, ok := nameSet[s.DisplayName()]; ok { + result = append(result, s) + } + } + if len(result) == 0 { + return nil, fmt.Errorf("no matching skills found") + } + return result, nil +} + +// collisionError checks for name collisions among the selected skills. +func collisionError(ss []discovery.Skill) error { + collisions := discovery.FindNameCollisions(ss) + if len(collisions) == 0 { + return nil + } + return fmt.Errorf("cannot install skills with conflicting names; they would overwrite each other:\n %s", + discovery.FormatCollisions(collisions)) +} + +func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, error) { + if opts.Agent != "" { + h, err := registry.FindByID(opts.Agent) + if err != nil { + return nil, err + } + return []*registry.AgentHost{h}, nil + } + + if !canPrompt { + h, err := registry.FindByID(registry.DefaultAgentID) + if err != nil { + return nil, err + } + return []*registry.AgentHost{h}, nil + } + + fmt.Fprintln(opts.IO.ErrOut) + labels := make([]string, len(registry.Agents)) + defaultLabel := "" + for i, h := range registry.Agents { + labels[i] = h.Name + if h.ID == registry.DefaultAgentID { + defaultLabel = labels[i] + } + } + if defaultLabel == "" { + defaultLabel = labels[0] + } + indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{defaultLabel}, labels) + if err != nil { + return nil, err + } + + if len(indices) == 0 { + return nil, fmt.Errorf("must select at least one target agent") + } + + selected := make([]*registry.AgentHost, len(indices)) + for i, idx := range indices { + selected[i] = ®istry.Agents[idx] + } + return selected, nil +} + +func resolveScope(opts *InstallOptions, canPrompt bool) (registry.Scope, error) { + if opts.Dir != "" { + return registry.Scope(opts.Scope), nil + } + + if opts.ScopeChanged || !canPrompt { + return registry.Scope(opts.Scope), nil + } + + var repoName string + if opts.Remotes != nil { + if remotes, err := opts.Remotes(); err == nil && len(remotes) > 0 { + repoName = ghrepo.FullName(remotes[0].Repo) + } + } + idx, err := opts.Prompter.Select("Installation scope:", "", registry.ScopeLabels(repoName)) + if err != nil { + return "", err + } + if idx == 0 { + return registry.ScopeProject, nil + } + return registry.ScopeUser, nil +} + +func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) { + byDir := make(map[string]*installPlan) + orderedDirs := make([]string, 0, len(selectedHosts)) + + for _, host := range selectedHosts { + targetDir, err := resolveInstallDir(opts, host, scope, gitRoot, homeDir) + if err != nil { + return nil, err + } + + plan, ok := byDir[targetDir] + if !ok { + plan = &installPlan{dir: targetDir} + byDir[targetDir] = plan + orderedDirs = append(orderedDirs, targetDir) + } + plan.hosts = append(plan.hosts, host) + } + + plans := make([]installPlan, 0, len(orderedDirs)) + for _, dir := range orderedDirs { + plan := byDir[dir] + installSkills, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt) + if err != nil { + return nil, err + } + if len(installSkills) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No skills to install in %s for %s.\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) + continue + } + plan.skills = installSkills + plans = append(plans, *plan) + } + + return plans, nil +} + +func resolveInstallDir(opts *InstallOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) { + if opts.Dir != "" { + return opts.Dir, nil + } + return host.InstallDir(scope, gitRoot, homeDir) +} + +func formatPlanHosts(hosts []*registry.AgentHost) string { + names := make([]string, len(hosts)) + for i, host := range hosts { + names[i] = host.Name + } + return strings.Join(names, ", ") +} + +func truncateDescription(s string, maxWidth int) string { + return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) +} + +func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { + var existing, fresh []discovery.Skill + for _, s := range skills { + dir := filepath.Join(targetDir, s.Name) + if _, err := os.Stat(dir); err == nil { + existing = append(existing, s) + } else { + fresh = append(fresh, s) + } + } + + if len(existing) == 0 { + return skills, nil + } + + if opts.Force { + return skills, nil + } + + if !canPrompt { + names := make([]string, len(existing)) + for i, s := range existing { + names[i] = s.DisplayName() + } + return nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", ")) + } + + var confirmed []discovery.Skill + for _, s := range existing { + prompt := existingSkillPrompt(targetDir, s) + ok, err := opts.Prompter.Confirm(prompt, false) + if err != nil { + return nil, err + } + if ok { + confirmed = append(confirmed, s) + } else { + fmt.Fprintf(opts.IO.ErrOut, "Skipping %s\n", s.DisplayName()) + } + } + + return append(fresh, confirmed...), nil +} + +func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { + skillFile := filepath.Join(targetDir, incoming.Name, "SKILL.md") + data, err := os.ReadFile(skillFile) + if err != nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } + + result, err := frontmatter.Parse(string(data)) + if err != nil || result.Metadata.Meta == nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } + + repoInfo, _, err := source.ParseMetadataRepo(result.Metadata.Meta) + ref, _ := result.Metadata.Meta["github-ref"].(string) + if err != nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } + + if repoInfo != nil { + sourceName := ghrepo.FullName(repoInfo) + if ref != "" { + sourceName += "@" + ref + } + return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), sourceName) + } + + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) +} + +const installProgressLabel = "Downloading skill files" + +func installProgress(io *iostreams.IOStreams, total int) func(done, total int) { + if total <= 0 { + return nil + } + return func(done, total int) { + if done == 0 { + io.StartProgressIndicatorWithLabel(installProgressLabel) + } else if done >= total { + io.StopProgressIndicator() + } + } +} + +func friendlyDir(dir string) string { + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + if rel == "." { + return filepath.Base(dir) + } + return rel + } + } + if home, err := os.UserHomeDir(); err == nil { + if rel, err := filepath.Rel(home, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "~/" + rel + } + } + return dir +} + +// printFileTree renders a text tree of the on-disk contents of each skill directory. +func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillNames []string) { + if len(skillNames) == 0 { + return + } + fmt.Fprintln(w) + for _, name := range skillNames { + skillDir := filepath.Join(dir, filepath.FromSlash(name)) + fmt.Fprintf(w, " %s\n", cs.Bold(name+"/")) + printTreeDir(w, cs, skillDir, " ") + } +} + +func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { + entries, err := os.ReadDir(dir) + if err != nil { + fmt.Fprintf(w, "%s%s\n", indent, cs.Muted("(could not read directory)")) + return + } + for i, entry := range entries { + isLast := i == len(entries)-1 + connector := "├── " + childIndent := "│ " + if isLast { + connector = "└── " + childIndent = " " + } + name := entry.Name() + if entry.IsDir() { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(name+"/")) + printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Muted(childIndent)) + } else { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), name) + } + } +} + +// printPreInstallDisclaimer prints a warning that installed skills are unverified +// and should be inspected before use. +func printPreInstallDisclaimer(w io.Writer, cs *iostreams.ColorScheme) { + fmt.Fprintf(w, "\n%s Skills are not verified by GitHub and may contain prompt injections, hidden instructions, or malicious scripts. Always review skill contents before use.\n\n", cs.WarningIcon()) +} + +// printReviewHint warns the user to review installed skills and suggests preview commands. +// When sha is non-empty the suggested commands include @SHA so the user previews +// exactly the version that was installed. When allowHiddenDirs is true, the +// suggested commands include --allow-hidden-dirs so previewing hidden-dir +// skills works without an extra manual step. +func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string, allowHiddenDirs bool) { + if len(skillNames) == 0 { + return + } + fmt.Fprintf(w, "\n%s Skills may contain prompt injections or malicious scripts.\n", cs.WarningIcon()) + if repo == "" { + fmt.Fprintln(w, " Review the installed files before use.") + return + } + fmt.Fprintln(w, " Review installed content before use:") + fmt.Fprintln(w) + hiddenFlag := "" + if allowHiddenDirs { + hiddenFlag = " --allow-hidden-dirs" + } + for _, name := range skillNames { + if sha != "" { + fmt.Fprintf(w, " gh skill preview %s %s@%s%s\n", repo, name, sha, hiddenFlag) + } else { + fmt.Fprintf(w, " gh skill preview %s %s%s\n", repo, name, hiddenFlag) + } + } + fmt.Fprintln(w) +} + +// printHostHints prints any agent-specific post-install guidance for the +// hosts that were installed to. Most agents need no extra steps; this is +// currently used for Kiro CLI, which requires skills to be registered as +// resources on a custom agent. The path in the example is derived from +// the actual install directory so it matches the chosen scope or --dir. +func printHostHints(w io.Writer, cs *iostreams.ColorScheme, hosts []*registry.AgentHost, installed []string, installDir, gitRoot string) { + if len(installed) == 0 { + return + } + for _, h := range hosts { + if h.ID == "kiro-cli" { + fmt.Fprintln(w) + fmt.Fprint(w, heredoc.Docf(` + %s Kiro CLI: register these skills on a custom agent by adding them to + .kiro/agents/.json under "resources", for example: + + { + "resources": ["skill://%s/**/SKILL.md"] + } + `, cs.WarningIcon(), kiroResourcePath(installDir, gitRoot))) + fmt.Fprintln(w) + return + } + } +} + +// kiroResourcePath returns a slash-separated path suitable for use in the +// "resources" field of a Kiro agent config. When the install directory is +// inside the current git repository the path is made relative to the repo +// root so the example works for project-scoped agent configs; otherwise +// the absolute install path is used (e.g. for --scope user or --dir). +func kiroResourcePath(installDir, gitRoot string) string { + if gitRoot != "" && installDir != "" { + if rel, err := filepath.Rel(gitRoot, installDir); err == nil && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(installDir) +} + +// filterHiddenDirSkills applies the --allow-hidden-dirs flag logic. When the +// flag is set, all skills are returned with a warning. Otherwise, hidden-dir +// skills are excluded with an error if no standard skills remain. +func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) { + cs := opts.IO.ColorScheme() + + if opts.AllowHiddenDirs { + if discovery.HasHiddenDirSkills(allSkills) { + fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` + %[1]s Skills in hidden directories (e.g. .claude/, .agents/) may be installed + copies from another publisher. Verify the skill's origin and check for a + canonical source. + `, cs.WarningIcon())) + } + return allSkills, nil + } + + r := discovery.PartitionHiddenDirSkills(allSkills) + if len(r.Standard) == 0 && r.HiddenCount > 0 { + return nil, fmt.Errorf( + "no standard skills found, but %d skill(s) exist in hidden directories\n"+ + " Use --allow-hidden-dirs to include them", + r.HiddenCount, + ) + } + + return r.Standard, nil +} + +// checkUpstreamProvenance fetches the skill's SKILL.md via the contents API +// to check if it contains github-repo metadata pointing to a different +// repository, indicating the skill was re-published from an upstream source. +// In interactive mode, the user is asked whether to install from the +// re-publisher or redirect to the upstream. Non-interactive mode always +// installs from the re-publisher. +// Returns (repo to redirect to, whether upstream was detected, error). +func checkUpstreamProvenance(opts *InstallOptions, client *api.Client, hostname string, skill discovery.Skill, commitSHA string) (ghrepo.Interface, bool, error) { + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", + opts.repo.RepoOwner(), opts.repo.RepoName(), + skill.Path+"/SKILL.md", commitSHA) + var fileResp struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := client.REST(hostname, "GET", apiPath, nil, &fileResp); err != nil { + return nil, false, nil //nolint:nilerr // best-effort check; failing to fetch is not fatal + } + if fileResp.Encoding != "base64" { + return nil, false, nil + } + decoded, decodeErr := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(fileResp.Content))) + if decodeErr != nil { + return nil, false, nil //nolint:nilerr // best-effort; decode failure is not fatal + } + content := string(decoded) + + result, parseErr := frontmatter.Parse(content) + if parseErr != nil || result.Metadata.Meta == nil { + //nolint:nilerr // unparseable frontmatter means no upstream to detect + return nil, false, nil + } + + existingRepo, _ := result.Metadata.Meta["github-repo"].(string) + if existingRepo == "" { + return nil, false, nil + } + + currentRepoURL := source.BuildRepoURL(hostname, opts.repo.RepoOwner(), opts.repo.RepoName()) + if existingRepo == currentRepoURL { + return nil, false, nil + } + + upstreamRepo, parseErr := source.ParseRepoURL(existingRepo) + if parseErr != nil { + //nolint:nilerr // invalid repo URL means we can't redirect; install normally + return nil, false, nil + } + + cs := opts.IO.ColorScheme() + upstreamLabel := ghrepo.FullName(upstreamRepo) + repoSource := ghrepo.FullName(opts.repo) + + fmt.Fprintf(opts.IO.ErrOut, "%s This skill was originally published in %s\n", cs.WarningIcon(), upstreamLabel) + + if opts.Upstream { + fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel) + return upstreamRepo, true, nil + } + + if !opts.IO.CanPrompt() { + fmt.Fprintf(opts.IO.ErrOut, " Installing from %s (use --upstream or interactive mode to choose upstream)\n", repoSource) + return nil, true, nil + } + + choices := []string{ + fmt.Sprintf("%s (re-publisher, recommended)", repoSource), + fmt.Sprintf("%s (upstream)", upstreamLabel), + } + idx, err := opts.Prompter.Select("Install from:", "", choices) + if err != nil { + return nil, true, err + } + + if idx == 1 { + fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel) + return upstreamRepo, true, nil + } + + return nil, true, nil +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go new file mode 100644 index 00000000000..64cded9bf67 --- /dev/null +++ b/pkg/cmd/skills/install/install_test.go @@ -0,0 +1,2776 @@ +package install + +import ( + "bytes" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdInstall(t *testing.T) { + tests := []struct { + name string + cli string + wantOpts InstallOptions + wantLocalPath bool + wantErr bool + }{ + { + name: "repo argument only", + cli: "monalisa/skills-repo", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, + { + name: "repo and skill", + cli: "monalisa/skills-repo git-commit", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + }, + { + name: "repo and all flag", + cli: "monalisa/skills-repo --all", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"}, + }, + { + name: "all flags", + cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", + wantOpts: InstallOptions{ + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + Pin: "v1.0.0", + Force: true, + }, + }, + { + name: "dir flag", + cli: "monalisa/skills-repo git-commit --dir ./custom-skills", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, + }, + { + name: "too many args", + cli: "a b c", + wantErr: true, + }, + { + name: "invalid agent flag", + cli: "monalisa/skills-repo git-commit --agent nonexistent", + wantErr: true, + }, + { + name: "pin conflicts with inline version", + cli: "monalisa/skills-repo git-commit@v1.0.0 --pin v2.0.0", + wantErr: true, + }, + { + name: "all conflicts with skill name", + cli: "monalisa/skills-repo git-commit --all", + wantErr: true, + }, + { + name: "alias add works", + cli: "monalisa/skills-repo git-commit", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + }, + { + name: "from-local flag sets localPath", + cli: "--from-local ./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project", FromLocal: true}, + wantLocalPath: true, + }, + { + name: "from-local with absolute path", + cli: "--from-local /absolute/path", + wantOpts: InstallOptions{SkillSource: "/absolute/path", Scope: "project", FromLocal: true}, + wantLocalPath: true, + }, + { + name: "from-local with tilde path", + cli: "--from-local ~/skills", + wantOpts: InstallOptions{SkillSource: "~/skills", Scope: "project", FromLocal: true}, + wantLocalPath: true, + }, + { + name: "owner/repo does not set localPath", + cli: "monalisa/skills-repo", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, + { + name: "local-looking path without --from-local treated as repo", + cli: "./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project"}, + }, + { + name: "from-local without argument errors", + cli: "--from-local", + wantErr: true, + }, + { + name: "from-local with --pin is mutually exclusive", + cli: "--from-local ./local-dir --pin v1.0.0", + wantErr: true, + }, + { + name: "allow-hidden-dirs flag", + cli: "monalisa/skills-repo --allow-hidden-dirs", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project", AllowHiddenDirs: true}, + }, + { + name: "upstream flag", + cli: "monalisa/skills-repo git-commit --upstream", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project", Upstream: true}, + }, + { + name: "from-local with --upstream is mutually exclusive", + cli: "--from-local ./local-dir --upstream", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + var gotOpts *InstallOptions + cmd := NewCmdInstall(f, &telemetry.NoOpService{}, func(opts *InstallOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantOpts.SkillSource, gotOpts.SkillSource) + assert.Equal(t, tt.wantOpts.SkillName, gotOpts.SkillName) + assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) + assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) + assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) + assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantOpts.All, gotOpts.All) + assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) + assert.Equal(t, tt.wantOpts.AllowHiddenDirs, gotOpts.AllowHiddenDirs) + if tt.wantLocalPath { + assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") + } else { + assert.Empty(t, gotOpts.localPath, "expected localPath to be empty") + } + }) + } + + // Verify command metadata separately. + t.Run("command metadata", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, &telemetry.NoOpService{}, nil) + + assert.Equal(t, "install [] [flags]", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + assert.Contains(t, cmd.Aliases, "add") + + for _, flag := range []string{"agent", "scope", "pin", "dir", "all", "force"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) + } + }) +} + +// --- HTTP stub helpers --- + +// stubResolveVersion registers API stubs for latest release + tag resolution. +func stubResolveVersion(reg *httpmock.Registry, owner, repo, tag, sha string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo)), + httpmock.StringResponse(fmt.Sprintf(`{"tag_name": %q}`, tag)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag)), + httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": %q, "type": "commit"}}`, sha)), + ) +} + +// stubDiscoverTree registers the single recursive-tree call used by DiscoverSkills. +func stubDiscoverTree(reg *httpmock.Registry, owner, repo, sha, treeJSON string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "tree": [%s]}`, sha, treeJSON)), + ) +} + +// stubInstallFiles registers subtree + blob stubs for installer.Install (one skill). +func stubInstallFiles(reg *httpmock.Registry, owner, repo, treeSHA, blobSHA, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": %q, "size": 50}]}`, blobSHA)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded)), + ) +} + +// stubSkillByPath registers stubs for DiscoverSkillByPath (contents API + tree). +func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillName, treeSHA string) { + parentPath := skillPath + if idx := strings.LastIndex(skillPath, "/"); idx >= 0 { + parentPath = skillPath[:idx] + } + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(parentPath))), + httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)), + ) +} + +// writeLocalTestSkill creates a skill directory with a SKILL.md file. +func writeLocalTestSkill(t *testing.T, baseDir, subPath, content string) { + t.Helper() + skillDir := filepath.Join(baseDir, subPath) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +// --- Skill content constants --- + +var gitCommitContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + --- + # Git Commit +`) + +var codeReviewContent = heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review +`) + +// singleSkillTreeJSON returns tree entries for a single skill with the given name. +func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": "skills/%s", "type": "tree", "sha": %q}, {"path": "skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + +// hiddenDirSkillTreeJSON returns tree entries for a hidden-dir skill under .claude/skills/. +func hiddenDirSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": ".claude/skills/%s", "type": "tree", "sha": %q}, {"path": ".claude/skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + +func TestInstallRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions + verify func(t *testing.T) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "non-interactive without repo errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "must specify a repository to install from", + }, + { + name: "non-interactive without skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "must specify a skill name when not running interactively", + }, + { + name: "remote install writes files with tracking metadata", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --agent claude-code", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install defaults to github-copilot non-interactively", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --scope user", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --dir bypasses scope resolution", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --force overwrites existing skill", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install existing skill without force non-interactive errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + } + }, + wantErr: "already installed", + }, + { + name: "remote install skill not found errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "nonexistent", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: `skill "nonexistent" not found`, + }, + { + name: "remote install ambiguous skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two namespaced skills with the same name + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "ambiguous", + }, + { + name: "remote install namespaced exact match resolves ambiguity", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob version\n---\n# B\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "bob/xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed bob/xlsx-pro", + }, + { + name: "remote install with invalid repo argument errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "invalid", + SkillName: "git-commit", + } + }, + wantErr: "invalid repository reference", + }, + { + name: "remote install with pin flag resolves version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v2.0.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "def456", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Pin: "v2.0.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v2.0.0", + }, + { + name: "remote install shows pre-install disclaimer", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "not verified by GitHub", + }, + { + name: "remote install outputs review hint", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "gh skill preview monalisa/skills-repo git-commit@abc123", + }, + { + name: "remote install outputs file tree for TTY", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "SKILL.md", + }, + { + name: "remote install with inline version parses name and version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v1.2.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit@v1.2.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v1.2.0", + }, + { + name: "remote install by skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", "skills/git-commit", "git-commit", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "skills/git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install by nested skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", + "terraform/code-generation/skills/terraform-style-guide", "terraform-style-guide", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "terraform/code-generation/skills/terraform-style-guide", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed terraform-style-guide", + }, + { + name: "remote install by arbitrary nested skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", + "packages/agent-skills/code-review", "code-review", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "packages/agent-skills/code-review", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed code-review", + }, + { + name: "remote install with URL repo argument", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "https://github.com/monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install all with collisions errors", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two skills with the same install name: skills/xlsx-pro and root xlsx-pro + treeJSON := `{"path": "skills/xlsx-pro", "type": "tree", "sha": "tree0"}, ` + + `{"path": "skills/xlsx-pro/SKILL.md", "type": "blob", "sha": "blob0"}, ` + + `{"path": "xlsx-pro", "type": "tree", "sha": "tree1"}, ` + + `{"path": "xlsx-pro/SKILL.md", "type": "blob", "sha": "blob1"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "conflicting names", + }, + { + name: "remote install all with namespaced skills detects collisions", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + // Blob stubs consumed by FetchDescriptionsConcurrent during interactive selection. + contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")) + contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobA"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobA", "content": %q, "encoding": "base64"}`, contentA))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB))) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "conflicting names", + }, + { + name: "remote install friendlyDir shows tilde for home paths", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive skill selection via prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + // 31 skills to exercise maxSearchResults cap + one without description + var treeEntries []string + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + treeEntries = append(treeEntries, + fmt.Sprintf(`{"path": "skills/%s", "type": "tree", "sha": "tree-%s"}`, name, name), + fmt.Sprintf(`{"path": "skills/%s/SKILL.md", "type": "blob", "sha": "blob-%s"}`, name, name)) + } + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + strings.Join(treeEntries, ", ")) + // Blob stubs for FetchDescriptionsConcurrent (one per skill) + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + blobSHA := fmt.Sprintf("blob-%s", name) + var content string + if i == 0 { + // First skill has no description (exercises else branch in label building) + content = fmt.Sprintf("---\nname: %s\n---\n# Skill\n", name) + } else { + content = fmt.Sprintf("---\nname: %s\ndescription: Does %s things\n---\n# Skill\n", name, name) + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s", blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded))) + } + // Install stubs for the selected skill (skill-01) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-skill-01", "blob-skill-01", + "---\nname: skill-01\ndescription: Does skill-01 things\n---\n# Skill\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + // Exercise searchFunc: empty query hits maxSearchResults cap (31 > 30) + all := searchFunc("") + if all.MoreResults == 0 { + return nil, fmt.Errorf("expected MoreResults > 0 for 31 skills") + } + // Non-empty query filters down + filtered := searchFunc("skill-01") + if len(filtered.Keys) == 0 { + return nil, fmt.Errorf("search returned no results") + } + return []string{filtered.Keys[0]}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed skill-01", + }, + { + name: "interactive scope prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite confirmation declined", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + destDir := t.TempDir() + writeLocalTestSkill(t, destDir, "git-commit", gitCommitContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + return false, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStderr: "No skills to install", + }, + { + name: "interactive host selection via MultiSelect", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0}, nil // select first agent + }, + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "scope prompt uses Remotes for repo name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + {Remote: &git.Remote{Name: "origin"}, Repo: ghrepo.New("monalisa", "octocat-skills")}, + }, nil + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite shows source info", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + destDir := t.TempDir() + existingContent := heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-repo: https://github.com/someowner/somerepo + github-ref: v0.5.0 + --- + # Git Commit + `) + writeLocalTestSkill(t, destDir, "git-commit", existingContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "someowner/somerepo@v0.5.0") + return true, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "unsupported host returns error", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "acme.ghes.com/monalisa/octocat-skills", + SkillName: "git-commit", + } + }, + wantErr: "does not currently support GitHub Enterprise Server", + }, + { + name: "select all skills in interactive prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Blob stub for FetchDescriptionsConcurrent + encoded := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-gc"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob-gc", "content": %q, "encoding": "base64"}`, encoded))) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"(all skills)"}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive repo prompt via Input", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive scope prompt selects user scope", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil // user scope + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive overwrite without metadata shows plain prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + destDir := t.TempDir() + // Existing skill without github metadata in frontmatter + writeLocalTestSkill(t, destDir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: No metadata + --- + # Git Commit + `)) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "already exists") + assert.NotContains(t, prompt, "installed from") + return true, nil + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install single exact match by name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeGC", "blobGC", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "multi-host install outputs per-host headers", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Two install rounds (one per host) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 1}, nil // select two agents + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Installing to", + }, + { + name: "hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "hidden-dir skills included with --allow-hidden-dirs", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Skills in hidden directories", + }, + { + name: "mixed tree without --allow-hidden-dirs returns only standard", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "mixed tree with --allow-hidden-dirs returns all", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA2", "blobSHA2", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "hidden-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed hidden-skill", + wantStderr: "Skills in hidden directories", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.stubs != nil { + tt.stubs(reg) + } + if tt.setup != nil { + tt.setup(t) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, reg) + if opts.Telemetry == nil { + opts.Telemetry = &telemetry.NoOpService{} + } + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t) + } + }) + } +} + +func TestInstallRun_AllInstallsRemoteSkills(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("code-review", "tree-cr", "blob-cr")+", "+ + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "skills-repo", "tree-cr", "blob-cr", codeReviewContent) + stubInstallFiles(reg, "monalisa", "skills-repo", "tree-gc", "blob-gc", gitCommitContent) + + ios, _, stdout, stderr := iostreams.Test() + targetDir := t.TempDir() + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + Telemetry: &telemetry.NoOpService{}, + }) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Installed code-review") + assert.Contains(t, stdout.String(), "Installed git-commit") + assert.NotContains(t, stderr.String(), "must specify a skill name") + require.FileExists(t, filepath.Join(targetDir, "code-review", "SKILL.md")) + require.FileExists(t, filepath.Join(targetDir, "git-commit", "SKILL.md")) +} + +func TestInstallProgress(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + assert.Nil(t, installProgress(ios, 0)) + assert.NotNil(t, installProgress(ios, 1)) + assert.NotNil(t, installProgress(ios, 2)) +} + +func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + // Select two agents that share the .agents/skills project dir + // (GitHub Copilot and Cursor) to exercise deduplication. + var indices []int + for i, label := range options { + if label == "GitHub Copilot" || label == "Cursor" { + indices = append(indices, i) + } + } + return indices, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + Telemetry: &telemetry.NoOpService{}, + }) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) + assert.NotContains(t, stderr.String(), "Installing to") +} + +func TestRunLocalInstall(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, sourceDir, targetDir string) + opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions + verify func(t *testing.T, targetDir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "installs skill with local-path metadata", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(targetDir, "git-commit", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(data), "local-path") + }, + wantStdout: "Installed git-commit", + }, + { + name: "single skill directory (SKILL.md at root)", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + content := heredoc.Doc(` + --- + name: direct-skill + description: Direct + --- + # Direct + `) + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "SKILL.md"), []byte(content), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "direct-skill", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed direct-skill", + }, + { + name: "namespaced skills with same name collide in flat install", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + Prompter: pm, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "conflicting names", + }, + { + name: "local install with --force overwrites namespaced skill flat", + isTTY: true, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "alice", "xlsx-pro"), + "---\nname: xlsx-pro\ndescription: alice xlsx-pro\n---\n# Test\n") + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "xlsx-pro"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md"), []byte("old"), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "xlsx-pro", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "alice xlsx-pro") + }, + wantStdout: "Installed", + }, + { + name: "local install existing skill without force non-interactive errors", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "already installed", + }, + { + name: "local install with no skills found errors", + isTTY: false, + setup: func(_ *testing.T, _, _ string) {}, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "anything", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no skills found", + }, + { + name: "local install outputs review hint", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStderr: "Review the installed files before use", + }, + { + name: "local install with --agent claude-code", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install by skill name selects one", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install outputs file tree for TTY", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + skillDir := filepath.Join(sourceDir, "skills", "git-commit") + require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: git-commit\ndescription: Commits\n---\n# A\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), + []byte("#!/bin/bash"), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStderr: "SKILL.md", + }, + { + name: "local path with tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &InstallOptions{ + IO: ios, + SkillSource: "~/", + localPath: "~/", + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local path with bare tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &InstallOptions{ + IO: ios, + SkillSource: "~", + localPath: "~", + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local skill not found by name", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "nonexistent-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "not found in local directory", + }, + { + name: "local hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "local hidden-dir skills included with --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + AllowHiddenDirs: true, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed code-review", + wantStderr: "Skills in hidden directories", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + sourceDir := t.TempDir() + targetDir := t.TempDir() + + if tt.setup != nil { + tt.setup(t, sourceDir, targetDir) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, sourceDir, targetDir) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, targetDir) + } + }) + } +} + +func Test_printReviewHint(t *testing.T) { + tests := []struct { + name string + repo string + sha string + skillNames []string + allowHiddenDirs bool + wantOutput string + }{ + { + name: "remote install with SHA includes SHA in preview command", + repo: "owner/repo", + sha: "abc123def456", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill@abc123def456", + }, + { + name: "remote install without SHA omits SHA from preview command", + repo: "owner/repo", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill\n", + }, + { + name: "multiple skills with SHA", + repo: "owner/repo", + sha: "deadbeef", + skillNames: []string{"skill-a", "skill-b"}, + wantOutput: "skill-a@deadbeef", + }, + { + name: "local install shows generic message", + repo: "", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "Review the installed files before use", + }, + { + name: "no skills produces no output", + repo: "owner/repo", + sha: "abc123", + skillNames: []string{}, + wantOutput: "", + }, + { + name: "allow-hidden-dirs appends flag to preview command", + repo: "owner/repo", + sha: "abc123", + skillNames: []string{"hidden-skill"}, + allowHiddenDirs: true, + wantOutput: "gh skill preview owner/repo hidden-skill@abc123 --allow-hidden-dirs", + }, + { + name: "allow-hidden-dirs without SHA", + repo: "owner/repo", + sha: "", + skillNames: []string{"hidden-skill"}, + allowHiddenDirs: true, + wantOutput: "gh skill preview owner/repo hidden-skill --allow-hidden-dirs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames, tt.allowHiddenDirs) + if tt.wantOutput == "" { + assert.Empty(t, buf.String()) + } else { + assert.Contains(t, buf.String(), tt.wantOutput) + } + }) + } +} + +func Test_printHostHints(t *testing.T) { + kiro := ®istry.AgentHost{ID: "kiro-cli", Name: "Kiro CLI", ProjectDir: ".kiro/skills", UserDir: ".kiro/skills"} + copilot := ®istry.AgentHost{ID: "copilot-cli", Name: "GitHub Copilot CLI", ProjectDir: ".github/skills"} + + tests := []struct { + name string + hosts []*registry.AgentHost + installed []string + installDir string + gitRoot string + wantSub []string + wantNot []string + }{ + { + name: "no installs produces no output", + hosts: []*registry.AgentHost{kiro}, + installed: nil, + installDir: "/repo/.kiro/skills", + gitRoot: "/repo", + wantNot: []string{"Kiro CLI"}, + }, + { + name: "non-kiro host produces no output", + hosts: []*registry.AgentHost{copilot}, + installed: []string{"s1"}, + installDir: "/repo/.github/skills", + gitRoot: "/repo", + wantNot: []string{"Kiro CLI"}, + }, + { + name: "kiro project scope uses relative path", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: filepath.Join("/repo", ".kiro", "skills"), + gitRoot: "/repo", + wantSub: []string{"Kiro CLI", `"skill://.kiro/skills/**/SKILL.md"`}, + }, + { + name: "kiro user scope uses absolute install dir", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/home/user/.kiro/skills", + gitRoot: "/repo", + wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, + wantNot: []string{`skill://.kiro/skills`}, + }, + { + name: "kiro custom dir outside git root uses absolute path", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/tmp/my-skills", + gitRoot: "/repo", + wantSub: []string{`"skill:///tmp/my-skills/**/SKILL.md"`}, + }, + { + name: "kiro without git root falls back to install dir", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/home/user/.kiro/skills", + gitRoot: "", + wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printHostHints(&buf, cs, tt.hosts, tt.installed, tt.installDir, tt.gitRoot) + got := buf.String() + for _, s := range tt.wantSub { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNot { + assert.NotContains(t, got, s) + } + }) + } +} + +func Test_printPreInstallDisclaimer(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printPreInstallDisclaimer(&buf, cs) + output := buf.String() + assert.Contains(t, output, "not verified by GitHub") + assert.Contains(t, output, "prompt") + assert.Contains(t, output, "malicious") +} + +func Test_selectSkillsWithSelector_noDisclaimer(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + skills := []discovery.Skill{ + {Name: "git-commit", Convention: "skills", Path: "skills/git-commit/SKILL.md"}, + } + + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"git-commit"}, nil + }, + } + + opts := &InstallOptions{ + IO: ios, + Prompter: pm, + } + + _, err := selectSkillsWithSelector(opts, skills, true, skillSelector{ + matchByName: matchSkillByName, + sourceHint: "owner/repo", + }) + require.NoError(t, err) + assert.NotContains(t, stderr.String(), "not verified by GitHub") +} + +func TestInstallRun_TelemetryVisibility(t *testing.T) { + tests := []struct { + name string + visibility string + visibilityErr bool + wantSkillNames string + }{ + { + name: "public repo includes skill names", + visibility: "public", + wantSkillNames: "git-commit", + }, + { + name: "private repo excludes skill names", + visibility: "private", + }, + { + name: "internal repo excludes skill names", + visibility: "internal", + }, + { + name: "API error omits visibility and skill names", + visibilityErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeSHA", "blobSHA", gitCommitContent) + if tt.visibilityErr { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.StatusStringResponse(500, "server error"), + ) + } else { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": tt.visibility, + }), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + recorder := &telemetry.EventRecorderSpy{} + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Force: true, + Telemetry: recorder, + }) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_install", event.Type) + assert.NotEmpty(t, event.Dimensions["agent_hosts"], "agent_hosts should always be present") + + // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). + assert.Equal(t, "github.com", event.Dimensions["skill_host_type"]) + + if tt.visibilityErr { + assert.Equal(t, "unknown", event.Dimensions["repo_visibility"], + "visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch") + } else { + assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"]) + } + + // Owner, repo, and skill names are only included when the repo + // is public; for private/internal/unknown they are omitted to + // avoid leaking identifiers of non-public repositories. + if tt.wantSkillNames != "" { + assert.Equal(t, "monalisa", event.Dimensions["skill_owner"]) + assert.Equal(t, "octocat-skills", event.Dimensions["skill_repo"]) + assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_names"]) + } else { + assert.Empty(t, event.Dimensions["skill_owner"]) + assert.Empty(t, event.Dimensions["skill_repo"]) + assert.Empty(t, event.Dimensions["skill_names"]) + } + }) + } +} + +func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { + codeReviewContent := heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}, ` + + `{"path": "skills/code-review", "type": "tree", "sha": "treeCR"}, ` + + `{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blobCR"}` + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", treeJSON) + + // Blob stubs for FetchDescriptionsConcurrent during interactive selection + encGC := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + encCR := base64.StdEncoding.EncodeToString([]byte(codeReviewContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobGC"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobGC", "content": %q, "encoding": "base64"}`, encGC))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobCR"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobCR", "content": %q, "encoding": "base64"}`, encCR))) + + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeGC", "blobGC", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeCR", "blobCR", codeReviewContent) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + }), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: pm, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Telemetry: recorder, + }) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_install", event.Type) + assert.Equal(t, "public", event.Dimensions["repo_visibility"]) + + // Verify comma-separated skill names (alphabetical order from DiscoverSkills) + names := strings.Split(event.Dimensions["skill_names"], ",") + assert.Len(t, names, 2) + assert.Contains(t, names, "code-review") + assert.Contains(t, names, "git-commit") +} + +var republishedContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-repo: https://github.com/monalisa/original-skills + github-tree-sha: upstreamTreeSHA + github-path: skills/git-commit + --- + # Git Commit +`) + +func stubContentsAPI(reg *httpmock.Registry, owner, repo, path, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path)), + httpmock.StringResponse(fmt.Sprintf(`{"content": %q, "encoding": "base64"}`, encoded)), + ) +} + +func TestInstallRun_UpstreamDetection(t *testing.T) { + tests := []struct { + name string + isTTY bool + stubs func(*httpmock.Registry) + opts func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "detects re-published skill and user picks re-publisher", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubInstallFiles(reg, "monalisa", "skills-repo", + "treeSHA", "blobSHA", republishedContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + SelectFunc: func(_ string, _ string, choices []string) (int, error) { + require.Len(t, choices, 2) + assert.Contains(t, choices[0], "monalisa/skills-repo") + assert.Contains(t, choices[1], "monalisa/original-skills") + return 0, nil + }, + }, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "originally published in monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + { + name: "detects re-published skill and user picks upstream", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") + stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", + singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) + stubContentsAPI(reg, "monalisa", "original-skills", + "skills/git-commit/SKILL.md", gitCommitContent) + stubInstallFiles(reg, "monalisa", "original-skills", + "upTreeSHA", "upBlobSHA", gitCommitContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + SelectFunc: func(_ string, _ string, choices []string) (int, error) { + require.Len(t, choices, 2) + assert.Contains(t, choices[0], "monalisa/skills-repo") + assert.Contains(t, choices[1], "monalisa/original-skills") + return 1, nil + }, + }, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "Redirecting install to monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + { + name: "non-interactive defaults to re-publisher with notice", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubInstallFiles(reg, "monalisa", "skills-repo", + "treeSHA", "blobSHA", republishedContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "use --upstream", + wantStdout: "Installed git-commit", + }, + { + name: "non-interactive with --upstream redirects to upstream", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") + stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", + singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) + stubContentsAPI(reg, "monalisa", "original-skills", + "skills/git-commit/SKILL.md", gitCommitContent) + stubInstallFiles(reg, "monalisa", "original-skills", + "upTreeSHA", "upBlobSHA", gitCommitContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Upstream: true, + } + }, + wantStderr: "Redirecting install to monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(t, ios, reg) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + }) + } +} diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go new file mode 100644 index 00000000000..c87f9829484 --- /dev/null +++ b/pkg/cmd/skills/list/list.go @@ -0,0 +1,583 @@ +package list + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" + "github.com/spf13/cobra" + "golang.org/x/text/transform" +) + +var skillListFields = []string{ + "skillName", + "agentHosts", + "scope", + "sourceURL", + "version", + "pinned", + "path", +} + +const ( + agentHostPublished = "published" + agentHostPublishedDisplay = "n/a (published)" + scopeCustom = "custom" +) + +type scanFilter int + +const ( + scanAllSkills scanFilter = iota + scanInstalledOnly + scanPublishedOnly +) + +type ListOptions struct { + IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder + GitClient *git.Client + Exporter cmdutil.Exporter + + Agent string + Scope string + ScopeChanged bool + Dir string +} + +type scanTarget struct { + dir string + agentHostIDs []string + scope string + filter scanFilter +} + +type listedSkill struct { + skillName string + agentHostIDs []string + scope string + source string + sourceURL string + version string + pinned bool + path string +} + +// ExportData implements cmdutil.exportable for --json output. +func (s listedSkill) ExportData(fields []string) map[string]interface{} { + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "skillName": + data[f] = s.skillName + case "agentHosts": + data[f] = s.agentHostIDs + case "scope": + data[f] = s.scope + case "sourceURL": + data[f] = s.sourceURL + case "version": + data[f] = s.version + case "pinned": + data[f] = s.pinned + case "path": + data[f] = s.path + } + } + return data +} + +// NewCmdList creates the "skills list" command. +func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Telemetry: telemetry, + GitClient: f.GitClient, + } + + cmd := &cobra.Command{ + Use: "list [flags]", + Short: "List installed skills (preview)", + Aliases: []string{"ls"}, + Long: heredoc.Docf(` + List installed agent skills across known agent host directories. + + By default, scans all supported agent hosts in both project and user scope. + Use %[1]s--agent%[1]s to scan one host, %[1]s--scope%[1]s to scan only project or user + scope, or %[1]s--dir%[1]s to scan a custom skills directory. + + Project-scope skills are discovered relative to the current git repository + root. User-scope skills are discovered relative to your home directory. + `, "`"), + Example: heredoc.Doc(` + # List all installed skills + $ gh skill list + + # List skills installed for GitHub Copilot + $ gh skill list --agent github-copilot + + # List user-scope skills + $ gh skill list --scope user + + # List skills as JSON + $ gh skill list --json skillName,sourceURL,scope,version,pinned,path + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opts.ScopeChanged = cmd.Flags().Changed("scope") + + if err := cmdutil.MutuallyExclusive("--dir and --agent cannot be used together", opts.Dir != "", opts.Agent != ""); err != nil { + return err + } + if err := cmdutil.MutuallyExclusive("--dir and --scope cannot be used together", opts.Dir != "", opts.ScopeChanged); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Filter by target agent") + cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "", []string{string(registry.ScopeProject), string(registry.ScopeUser)}, "Filter by installation scope") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, skillListFields) + + return cmd +} + +func listRun(opts *ListOptions) error { + skills, err := listInstalledSkills(opts) + if err != nil { + return err + } + sortListedSkills(skills) + recordListTelemetry(opts, len(skills)) + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, skills) + } + + if len(skills) == 0 { + return cmdutil.NewNoResultsError("no installed skills found") + } + + return renderTable(opts.IO, skills) +} + +func listInstalledSkills(opts *ListOptions) ([]listedSkill, error) { + targets, err := buildScanTargets(opts) + if err != nil { + return nil, err + } + + var all []listedSkill + for _, target := range targets { + skills, scanErr := scanInstalledSkills(target.dir, target.agentHostIDs, target.scope, target.filter) + if scanErr != nil { + if opts.Dir != "" { + return nil, fmt.Errorf("could not scan directory: %w", scanErr) + } + continue + } + all = append(all, skills...) + } + + return all, nil +} + +func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { + if opts.Dir != "" { + dir, err := filepath.Abs(opts.Dir) + if err != nil { + return nil, fmt.Errorf("could not resolve path: %w", err) + } + if _, err := os.Stat(dir); err != nil { + return nil, fmt.Errorf("could not access directory: %w", err) + } + return []scanTarget{{dir: dir, scope: scopeCustom}}, nil + } + + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() + + agentHosts, err := selectedAgentHosts(opts.Agent) + if err != nil { + return nil, err + } + scopes := selectedScopes(opts.Scope) + + byDir := map[string]int{} + var targets []scanTarget + for _, agentHost := range agentHosts { + for _, scope := range scopes { + dir, installErr := agentHost.InstallDir(scope, gitRoot, homeDir) + if installErr != nil { + continue + } + + if idx, ok := byDir[dir]; ok { + targets[idx].agentHostIDs = appendAgentHostID(targets[idx].agentHostIDs, agentHost.ID) + targets[idx].filter = mergeScanFilters(targets[idx].filter, scanFilterForAgentHost(agentHost, scope)) + continue + } + + byDir[dir] = len(targets) + targets = append(targets, scanTarget{ + dir: dir, + agentHostIDs: []string{agentHost.ID}, + scope: string(scope), + filter: scanFilterForAgentHost(agentHost, scope), + }) + } + } + if shouldListPublishedProjectSkills(opts.Agent, scopes, gitRoot) { + targets = append(targets, scanTarget{ + dir: filepath.Join(gitRoot, "skills"), + agentHostIDs: []string{agentHostPublished}, + scope: string(registry.ScopeProject), + filter: scanPublishedOnly, + }) + } + + return targets, nil +} + +func selectedAgentHosts(agentID string) ([]*registry.AgentHost, error) { + if agentID != "" { + host, err := registry.FindByID(agentID) + if err != nil { + return nil, err + } + return []*registry.AgentHost{host}, nil + } + + agentHosts := make([]*registry.AgentHost, len(registry.Agents)) + for i := range registry.Agents { + agentHosts[i] = ®istry.Agents[i] + } + return agentHosts, nil +} + +func selectedScopes(scope string) []registry.Scope { + if scope != "" { + return []registry.Scope{registry.Scope(scope)} + } + return []registry.Scope{registry.ScopeProject, registry.ScopeUser} +} + +func appendAgentHostID(agentHostIDs []string, agentHostID string) []string { + for _, existing := range agentHostIDs { + if existing == agentHostID { + return agentHostIDs + } + } + return append(agentHostIDs, agentHostID) +} + +func scanFilterForAgentHost(agentHost *registry.AgentHost, scope registry.Scope) scanFilter { + if scope == registry.ScopeProject && agentHost.ProjectDir == "skills" { + return scanInstalledOnly + } + return scanAllSkills +} + +func mergeScanFilters(a, b scanFilter) scanFilter { + if a == b { + return a + } + return scanAllSkills +} + +func shouldListPublishedProjectSkills(agentID string, scopes []registry.Scope, gitRoot string) bool { + if agentID != "" || gitRoot == "" { + return false + } + for _, scope := range scopes { + if scope == registry.ScopeProject { + return true + } + } + return false +} + +func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string, filter scanFilter) ([]listedSkill, error) { + entries, err := os.ReadDir(skillsDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("could not read skills directory: %w", err) + } + + var skills []listedSkill + for _, e := range entries { + if !e.IsDir() { + continue + } + + // Flat layout: {dir}/{name}/SKILL.md. + skillDir := filepath.Join(skillsDir, e.Name()) + skillFile := filepath.Join(skillDir, "SKILL.md") + // TODO: maybe we should surface this error instead of a silent skip + if data, readErr := readSkillFile(skillFile); readErr == nil { + skill, hasInstallMetadata := parseInstalledSkill(data, e.Name(), skillDir, agentHostIDs, scope) + if shouldIncludeSkill(filter, hasInstallMetadata) { + skills = append(skills, skill) + } + continue + } + + // Namespaced layout: {dir}/{namespace}/{name}/SKILL.md. + subEntries, subErr := os.ReadDir(skillDir) + if subErr != nil { + continue + } + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + subSkillDir := filepath.Join(skillDir, sub.Name()) + subSkillFile := filepath.Join(subSkillDir, "SKILL.md") + if data, readErr := readSkillFile(subSkillFile); readErr == nil { + installName := e.Name() + "/" + sub.Name() + skill, hasInstallMetadata := parseInstalledSkill(data, installName, subSkillDir, agentHostIDs, scope) + if shouldIncludeSkill(filter, hasInstallMetadata) { + skills = append(skills, skill) + } + } + } + } + + return skills, nil +} + +// readSkillFile reads a SKILL.md file only if it resolves to a regular file. +func readSkillFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("SKILL.md is not a regular file: %s", path) + } + return os.ReadFile(path) +} + +func shouldIncludeSkill(filter scanFilter, hasInstallMetadata bool) bool { + switch filter { + case scanInstalledOnly: + return hasInstallMetadata + case scanPublishedOnly: + return !hasInstallMetadata + default: + return true + } +} + +func parseInstalledSkill(data []byte, name, dir string, agentHostIDs []string, scope string) (listedSkill, bool) { + s := listedSkill{ + skillName: name, + agentHostIDs: agentHostIDs, + scope: scope, + path: dir, + } + + result, err := frontmatter.Parse(string(data)) + if err != nil { + return s, false + } + + meta := result.Metadata.Meta + if meta == nil { + return s, false + } + installMetadata := hasInstallMetadata(meta) + + if sourcePath, _ := meta["github-path"].(string); sourcePath != "" { + if skillName := skillNameFromSourcePath(sourcePath); skillName != "" { + s.skillName = skillName + } + } + + if repoURL, _ := meta["github-repo"].(string); repoURL != "" { + s.sourceURL = repoURL + s.source = repoURL + if repo, parseErr := source.ParseRepoURL(repoURL); parseErr == nil { + s.source = ghrepo.FullName(repo) + s.sourceURL = source.BuildRepoURL(repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) + } + } else if localPath, _ := meta["local-path"].(string); localPath != "" { + s.sourceURL = localPath + s.source = localPath + } + + if ref, _ := meta["github-ref"].(string); ref != "" { + s.version = discovery.ShortRef(ref) + } + if pinnedRef, _ := meta["github-pinned"].(string); pinnedRef != "" { + s.pinned = true + if s.version == "" { + s.version = pinnedRef + } + } + + return s, installMetadata +} + +func hasInstallMetadata(meta map[string]interface{}) bool { + for _, key := range []string{"github-repo", "github-ref", "github-tree-sha", "github-path", "github-pinned", "local-path"} { + value, ok := meta[key] + if !ok { + continue + } + if str, ok := value.(string); !ok || strings.TrimSpace(str) != "" { + return true + } + } + return false +} + +func skillNameFromSourcePath(sourcePath string) string { + sourcePath = strings.TrimSuffix(sourcePath, "/SKILL.md") + sourcePath = strings.Trim(sourcePath, "/") + if sourcePath == "" { + return "" + } + + parts := strings.Split(sourcePath, "/") + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "skills" { + continue + } + + if i >= 2 && parts[i-2] == "plugins" && i+1 < len(parts) { + return parts[i-1] + "/" + parts[len(parts)-1] + } + + afterSkills := len(parts) - i - 1 + switch afterSkills { + case 0: + return "" + case 1: + return parts[i+1] + default: + return parts[i+1] + "/" + parts[len(parts)-1] + } + } + + return parts[len(parts)-1] +} + +func sortListedSkills(skills []listedSkill) { + sort.Slice(skills, func(i, j int) bool { + if skills[i].skillName != skills[j].skillName { + return skills[i].skillName < skills[j].skillName + } + if skills[i].scope != skills[j].scope { + return skills[i].scope < skills[j].scope + } + if formatAgentHosts(skills[i].agentHostIDs) != formatAgentHosts(skills[j].agentHostIDs) { + return formatAgentHosts(skills[i].agentHostIDs) < formatAgentHosts(skills[j].agentHostIDs) + } + return skills[i].path < skills[j].path + }) +} + +func renderTable(io *iostreams.IOStreams, skills []listedSkill) error { + table := tableprinter.New(io, tableprinter.WithHeader("Name", "Agent", "Scope", "Source")) + + for _, skill := range skills { + table.AddField(sanitizeForTerminal(skill.skillName)) + table.AddField(formatAgentHosts(skill.agentHostIDs)) + table.AddField(displayOrDash(skill.scope)) + table.AddField(displayOrDash(sanitizeForTerminal(skill.source))) + table.EndRow() + } + + return table.Render() +} + +// sanitizeForTerminal replaces ASCII control characters in s with inert +// caret-style stand-ins so frontmatter values cannot inject terminal escapes. +func sanitizeForTerminal(s string) string { + var buf bytes.Buffer + r := transform.NewReader(bytes.NewReader([]byte(s)), &asciisanitizer.Sanitizer{}) + if _, err := io.Copy(&buf, r); err != nil { + return "Unknown" + } + return buf.String() +} + +func displayOrDash(value string) string { + if value == "" { + return "-" + } + return value +} + +func formatAgentHosts(agentHostIDs []string) string { + if len(agentHostIDs) == 0 { + return "-" + } + if len(agentHostIDs) == 1 && agentHostIDs[0] == agentHostPublished { + return agentHostPublishedDisplay + } + return strings.Join(agentHostIDs, ", ") +} + +func recordListTelemetry(opts *ListOptions, skillCount int) { + if opts.Telemetry == nil { + return + } + + agentHosts := opts.Agent + if agentHosts == "" { + agentHosts = "all" + } + scope := opts.Scope + if scope == "" { + scope = "all" + } + customDir := "false" + if opts.Dir != "" { + customDir = "true" + scope = scopeCustom + } + format := "table" + if opts.Exporter != nil { + format = "json" + } + + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_list", + Dimensions: ghtelemetry.Dimensions{ + "agent_hosts": agentHosts, + "custom_dir": customDir, + "format": format, + "scope": scope, + }, + Measures: ghtelemetry.Measures{ + "skill_count": int64(skillCount), + }, + }) +} diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go new file mode 100644 index 00000000000..94295c7ba6a --- /dev/null +++ b/pkg/cmd/skills/list/list_test.go @@ -0,0 +1,535 @@ +package list + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wantOpts ListOptions + wantJSON bool + wantErr string + }{ + { + name: "no flags", + cli: "", + wantOpts: ListOptions{}, + }, + { + name: "agent and scope filters", + cli: "--agent github-copilot --scope user", + wantOpts: ListOptions{ + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + }, + }, + { + name: "custom dir", + cli: "--dir ./skills", + wantOpts: ListOptions{ + Dir: "./skills", + }, + }, + { + name: "json fields", + cli: "--json skillName,sourceURL,scope,version,pinned,path", + wantJSON: true, + }, + { + name: "too many args", + cli: "extra", + wantErr: "unknown command", + }, + { + name: "invalid agent", + cli: "--agent unknown", + wantErr: "invalid argument", + }, + { + name: "invalid scope", + cli: "--scope org", + wantErr: "invalid argument", + }, + { + name: "dir and agent are mutually exclusive", + cli: "--dir ./skills --agent github-copilot", + wantErr: "--dir and --agent cannot be used together", + }, + { + name: "dir and scope are mutually exclusive", + cli: "--dir ./skills --scope user", + wantErr: "--dir and --scope cannot be used together", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + GitClient: &git.Client{}, + } + + var gotOpts *ListOptions + cmd := NewCmdList(f, &telemetry.NoOpService{}, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err = cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) + assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) + assert.Equal(t, tt.wantOpts.ScopeChanged, gotOpts.ScopeChanged) + assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) + if tt.wantJSON { + assert.NotNil(t, gotOpts.Exporter) + } + }) + } +} + +func TestListRun(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, repoDir, homeDir string) + opts func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions + wantStdout string + wantJSON string + wantErr string + verify func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) + }{ + { + name: "lists project skill for selected shared agent", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/git-commit", remoteSkillFrontmatter("git-commit", "skills/git-commit", "refs/tags/v1.0.0", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "cursor", + Scope: "project", + } + }, + wantStdout: "git-commit\tcursor\tproject\tmonalisa/skills-repo\n", + verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) { + require.Len(t, spy.Events, 1) + event := spy.Events[0] + assert.Equal(t, "skill_list", event.Type) + assert.Equal(t, "cursor", event.Dimensions["agent_hosts"]) + assert.Equal(t, "project", event.Dimensions["scope"]) + assert.Equal(t, int64(1), event.Measures["skill_count"]) + }, + }, + { + name: "lists user skill as json", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, homeDir, ".copilot/skills/code-review", remoteSkillFrontmatter("code-review", "skills/code-review", "refs/tags/v2.0.0", "v2.0.0")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName", "agentHosts", "scope", "sourceURL", "version", "pinned", "path"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "github-copilot", + Scope: "user", + } + }, + wantJSON: fmt.Sprintf(`[ + { + "skillName": "code-review", + "agentHosts": ["github-copilot"], + "scope": "user", + "sourceURL": "https://github.com/monalisa/skills-repo", + "version": "v2.0.0", + "pinned": true, + "path": %q + } + ]`, filepath.Join("HOME", ".copilot", "skills", "code-review")), + verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) { + assert.Equal(t, "json", spy.Events[0].Dimensions["format"]) + }, + }, + { + name: "preserves tenant host in json source url", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, homeDir, ".copilot/skills/tenant-skill", remoteSkillFrontmatterForRepo("tenant-skill", "https://octocorp.ghe.com/monalisa/skills-repo", "skills/tenant-skill", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName", "sourceURL", "path"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "github-copilot", + Scope: "user", + } + }, + wantJSON: fmt.Sprintf(`[ + { + "skillName": "tenant-skill", + "sourceURL": "https://octocorp.ghe.com/monalisa/skills-repo", + "path": %q + } + ]`, filepath.Join("HOME", ".copilot", "skills", "tenant-skill")), + }, + { + name: "custom directory with local metadata", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + writeSkill(t, customDir, "local-helper", heredoc.Doc(` + --- + name: local-helper + metadata: + local-path: /src/local-helper + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantStdout: "local-helper\t-\tcustom\t/src/local-helper\n", + }, + { + name: "custom directory must exist", + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "missing-skills"), + } + }, + wantErr: "could not access directory", + }, + { + name: "lists source skills in bare project skills directory as published", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, "skills/gh", heredoc.Doc(` + --- + name: gh + description: GitHub CLI patterns + --- + Body + `)) + writeSkill(t, repoDir, "skills/gh-skill", heredoc.Doc(` + --- + name: gh-skill + description: GitHub Skill patterns + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Scope: "project", + } + }, + wantStdout: "gh\tn/a (published)\tproject\t-\ngh-skill\tn/a (published)\tproject\t-\n", + }, + { + name: "lists openclaw project skill with install metadata", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, "skills/openclaw-helper", remoteSkillFrontmatter("openclaw-helper", "skills/openclaw-helper", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "openclaw", + Scope: "project", + } + }, + wantStdout: "openclaw-helper\topenclaw\tproject\tmonalisa/skills-repo\n", + }, + { + name: "recovers namespaced skill name from source path", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/xlsx-pro", remoteSkillFrontmatter("xlsx-pro", "skills/bob/xlsx-pro", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "github-copilot", + Scope: "project", + } + }, + wantStdout: "bob/xlsx-pro\tgithub-copilot\tproject\tmonalisa/skills-repo\n", + }, + { + name: "recovers plugin skill name from source path", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/foo", remoteSkillFrontmatter("foo", "plugins/myplugin/skills/foo", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "github-copilot", + Scope: "project", + } + }, + wantStdout: "myplugin/foo\tgithub-copilot\tproject\tmonalisa/skills-repo\n", + }, + { + name: "partial metadata has empty json source url", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/partial", heredoc.Doc(` + --- + name: partial + metadata: + github-ref: refs/heads/main + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName", "sourceURL", "version", "pinned"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "github-copilot", + Scope: "project", + } + }, + wantJSON: `[ + { + "skillName": "partial", + "sourceURL": "", + "version": "main", + "pinned": false + } + ]`, + }, + { + name: "no installed skills returns no results", + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "github-copilot", + Scope: "project", + } + }, + wantErr: "no installed skills found", + }, + { + name: "no installed skills with json returns empty array", + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "github-copilot", + Scope: "project", + } + }, + wantJSON: "[]", + }, + { + name: "lists skill whose SKILL.md is a symlink to a regular file", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + skillDir := filepath.Join(customDir, "linked") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + target := filepath.Join(repoDir, "target.md") + require.NoError(t, os.WriteFile(target, []byte("---\nname: linked\nmetadata:\n local-path: /src/linked\n---\nBody\n"), 0o644)) + require.NoError(t, os.Symlink(target, filepath.Join(skillDir, "SKILL.md"))) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantStdout: "linked\t-\tcustom\t/src/linked\n", + }, + { + name: "skips skill whose SKILL.md is not a regular file", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + skillDir := filepath.Join(customDir, "bogus") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + targetDir := filepath.Join(repoDir, "target-dir") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + require.NoError(t, os.Symlink(targetDir, filepath.Join(skillDir, "SKILL.md"))) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantErr: "no installed skills found", + }, + { + name: "sanitizes terminal escapes from skill frontmatter", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + writeSkill(t, customDir, "helper", heredoc.Doc(` + --- + name: helper + metadata: + local-path: "/src/\x1b[33munsanitized-src\x1b[0m" + github-path: "skills/\x1b[31munsanitized-name\x1b[0m/SKILL.md" + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantStdout: "^[[31munsanitized-name^[[0m\t-\tcustom\t/src/^[[33munsanitized-src^[[0m\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoDir := t.TempDir() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + if tt.setup != nil { + tt.setup(t, repoDir, homeDir) + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + spy := &telemetry.CommandRecorderSpy{} + opts := tt.opts(ios, repoDir, homeDir, spy) + + err := listRun(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantJSON != "" { + expected := tt.wantJSON + expected = strings.ReplaceAll(expected, "HOME", strings.ReplaceAll(homeDir, `\`, `\\`)) + assert.JSONEq(t, expected, stdout.String()) + } else { + assert.Equal(t, tt.wantStdout, stdout.String()) + } + if tt.verify != nil { + tt.verify(t, stdout.String(), spy) + } + }) + } +} + +func TestRenderTableUsesAgentHeader(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + err := renderTable(ios, []listedSkill{{ + skillName: "git-commit", + agentHostIDs: []string{"github-copilot", "cursor"}, + scope: "project", + source: "monalisa/skills-repo", + version: "v1.0.0", + }}) + + require.NoError(t, err) + assert.Contains(t, stdout.String(), "AGENT") + assert.Contains(t, stdout.String(), "github-copilot, cursor") + assert.NotContains(t, stdout.String(), "HOST") +} + +func writeSkill(t *testing.T, baseDir, relDir, content string) { + t.Helper() + skillDir := filepath.Join(baseDir, filepath.FromSlash(relDir)) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +func remoteSkillFrontmatter(name, sourcePath, ref, pinned string) string { + return remoteSkillFrontmatterForRepo(name, "https://github.com/monalisa/skills-repo", sourcePath, ref, pinned) +} + +func remoteSkillFrontmatterForRepo(name, repoURL, sourcePath, ref, pinned string) string { + pinnedLine := "" + if pinned != "" { + pinnedLine = fmt.Sprintf(" github-pinned: %s\n", pinned) + } + return fmt.Sprintf(heredoc.Doc(` + --- + name: %s + metadata: + github-repo: %s + github-ref: %s + github-tree-sha: abc123 + github-path: %s + %s--- + Body + `), name, repoURL, ref, sourcePath, pinnedLine) +} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go new file mode 100644 index 00000000000..06d50154c91 --- /dev/null +++ b/pkg/cmd/skills/preview/preview.go @@ -0,0 +1,556 @@ +package preview + +import ( + "fmt" + "io" + "net/http" + "path" + "sort" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/source" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + "github.com/spf13/cobra" +) + +type PreviewOptions struct { + IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + ExecutablePath string + RenderFile func(string, string) string + + RepoArg string + SkillName string + Version string // resolved from @suffix on SkillName + AllowHiddenDirs bool // include skills in dot-prefixed directories + + repo ghrepo.Interface +} + +// NewCmdPreview creates the "skills preview" command. +func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*PreviewOptions) error) *cobra.Command { + opts := &PreviewOptions{ + IO: f.IOStreams, + Telemetry: telemetry, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + ExecutablePath: f.ExecutablePath, + } + opts.RenderFile = func(filePath, content string) string { + return renderMarkdownPreview(opts.IO, filePath, content) + } + + cmd := &cobra.Command{ + Use: "preview []", + Short: "Preview a skill from a GitHub repository (preview)", + Long: heredoc.Docf(` + Render a skill's %[1]sSKILL.md%[1]s content in the terminal. This fetches the + skill file from the repository and displays it using the configured + pager, without installing anything. + + A file tree is shown first, followed by the rendered %[1]sSKILL.md%[1]s content. + When running interactively and the skill contains additional files + (scripts, references, etc.), a file picker lets you browse them + individually. + + When run with only a repository argument, lists available skills and + prompts for selection. + + The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), + or an exact path within the repository (%[1]sskills/author/skill%[1]s, + %[1]spackages/agent-skills/code-review%[1]s, or any %[1]s.../SKILL.md%[1]s path). + Namespaced names with one slash are matched by name. Use a %[1]sSKILL.md%[1]s + suffix to force a one-directory path outside the standard conventions. + + To preview a specific version of the skill, append %[1]s@VERSION%[1]s to the + skill name. The version is resolved as a git tag, branch, or commit SHA. + `, "`"), + Example: heredoc.Doc(` + # Preview a specific skill + $ gh skill preview github/awesome-copilot documentation-writer + + # Preview a skill at a specific version + $ gh skill preview github/awesome-copilot documentation-writer@v1.2.0 + + # Preview a skill at a specific commit SHA + $ gh skill preview github/awesome-copilot documentation-writer@abc123def456 + + # Preview from a non-standard nested path (efficient, skips full discovery) + $ gh skill preview monalisa/skills-repo packages/agent-skills/code-review + + # Browse and preview interactively + $ gh skill preview github/awesome-copilot + `), + Aliases: []string{"show"}, + Args: cobra.RangeArgs(1, 2), + RunE: func(c *cobra.Command, args []string) error { + opts.RepoArg = args[0] + if len(args) == 2 { + opts.SkillName = args[1] + } + + if i := strings.LastIndex(opts.SkillName, "@"); i > 0 { + opts.Version = opts.SkillName[i+1:] + opts.SkillName = opts.SkillName[:i] + } + + repo, err := ghrepo.FromFullName(opts.RepoArg) + if err != nil { + return err + } + opts.repo = repo + + if runF != nil { + return runF(opts) + } + return previewRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") + + return cmd +} + +func previewRun(opts *PreviewOptions) error { + cs := opts.IO.ColorScheme() + + repo := opts.repo + owner := repo.RepoOwner() + repoName := repo.RepoName() + hostname := repo.RepoHost() + if err := source.ValidateSupportedHost(hostname); err != nil { + return err + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + // Kick off the visibility fetch in parallel with the preview work so + // the extra API roundtrip doesn't add latency on the critical path. + // The result is consumed when the telemetry event is emitted below. + type visResult struct { + vis discovery.RepoVisibility + err error + } + visCh := make(chan visResult, 1) + go func() { + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, owner, repoName) + visCh <- visResult{vis: vis, err: err} + }() + + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) + resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, opts.Version) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("could not resolve version: %w", err) + } + + var skill discovery.Skill + if discovery.IsSkillPath(opts.SkillName) { + opts.IO.StartProgressIndicatorWithLabel("Looking up skill") + found, err := discovery.DiscoverSkillByPathWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, opts.SkillName, discovery.DiscoverSkillByPathOptions{SkipDescription: true}) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + skill = *found + } else { + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + allSkills, err := discovery.DiscoverSkillsWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, discovery.DiscoverOptions{}) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + skills, err := filterHiddenDirSkills(opts, allSkills) + if err != nil { + return err + } + + sort.Slice(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + + skill, err = selectSkill(opts, skills) + if err != nil { + return err + } + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching skill content") + var files []discovery.SkillFile + if skill.TreeSHA != "" { + files, err = discovery.ListSkillFiles(apiClient, hostname, owner, repoName, skill.TreeSHA) + if err != nil { + fmt.Fprintf(opts.IO.ErrOut, "warning: could not list skill files: %v\n", err) + files = nil + } + } + content, err := discovery.FetchBlob(apiClient, hostname, owner, repoName, skill.BlobSHA) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + rendered := opts.renderFile("SKILL.md", content) + + // Collect extra files (everything that isn't SKILL.md) + var extraFiles []discovery.SkillFile + for _, f := range files { + if f.Path != "SKILL.md" { + extraFiles = append(extraFiles, f) + } + } + + canPrompt := opts.IO.CanPrompt() + + // Non-interactive or skill has only SKILL.md: dump through pager + if !canPrompt || len(extraFiles) == 0 { + renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + } else { + // Interactive with multiple files: show tree, then file picker + renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + } + + dims := map[string]string{ + "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + } + select { + case r := <-visCh: + if r.err == nil { + dims["repo_visibility"] = string(r.vis) + if r.vis == discovery.RepoVisibilityPublic { + dims["skill_owner"] = opts.repo.RepoOwner() + dims["skill_repo"] = opts.repo.RepoName() + dims["skill_name"] = skill.DisplayName() + } + } else { + dims["repo_visibility"] = "unknown" + } + case <-time.After(visibilityWaitTimeout): + dims["repo_visibility"] = "unknown" + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_preview", + Dimensions: dims, + }) + + return nil +} + +// visibilityWaitTimeout is how long to wait at telemetry-emit time for +// the in-flight repo visibility fetch before giving up and emitting +// repo_visibility="unknown". By this point the command has already done +// several serial API calls and rendering work, so the fetch has almost +// always completed; this budget is a short safety net for the case +// where that single REST call has stalled. +const visibilityWaitTimeout = 200 * time.Millisecond + +// renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. +func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, + files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, + apiClient *api.Client, hostname, owner, repo string) { + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err) + } + defer opts.IO.StopPager() + + out := opts.IO.Out + + if len(files) > 0 { + fmt.Fprintf(out, "%s\n", cs.Bold(skill.DisplayName()+"/")) + renderFileTree(out, cs, files) + fmt.Fprintln(out) + } + + fmt.Fprintf(out, "%s\n\n", cs.Bold("── SKILL.md ──")) + fmt.Fprint(out, rendered) + + const maxFiles = 20 + const maxTotalBytes = 512 * 1024 + fetched := 0 + totalBytes := 0 + for _, f := range extraFiles { + if fetched >= maxFiles { + fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files, showing first %d)", maxFiles))) + break + } + if totalBytes+f.Size > maxTotalBytes { + fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files, size limit reached)")) + break + } + fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) + if fetchErr != nil { + fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Muted("(could not fetch file)")) + continue + } + fetched++ + totalBytes += len(fileContent) + fmt.Fprintf(out, "\n%s\n\n", cs.Bold("── "+f.Path+" ──")) + fmt.Fprint(out, fileContent) + if !strings.HasSuffix(fileContent, "\n") { + fmt.Fprintln(out) + } + } +} + +// renderInteractive shows the file tree, then a picker to browse individual files. +func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, + files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, + apiClient *api.Client, hostname, owner, repo string) { + + // Show the file tree to stderr so it persists above the prompt + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", cs.Bold(skill.DisplayName()+"/")) + renderFileTree(opts.IO.ErrOut, cs, files) + fmt.Fprintln(opts.IO.ErrOut) + + // Build choices: SKILL.md first, then extra files + choices := make([]string, 0, len(extraFiles)+1) + choices = append(choices, "SKILL.md") + for _, f := range extraFiles { + choices = append(choices, f.Path) + } + + // Save original stdout. StopPager closes IO.Out, so we need to + // restore a working writer before each StartPager call. + originalOut := opts.IO.Out + + for { + // Restore original Out before each pager cycle. StartPager replaces + // IO.Out with a pipe; StopPager closes that pipe but does not + // restore the original. The original writer remains valid. + opts.IO.Out = originalOut + + idx, err := opts.Prompter.Select("View a file (Esc to exit):", "", choices) + if err != nil { + return // Prompter returns error on Esc/Ctrl-C; treat as graceful exit + } + + var content string + + if idx == 0 { + content = renderedSkillMD + } else { + selectedFile := extraFiles[idx-1] + + // Fetch on demand; don't hold blob data in memory + fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, selectedFile.SHA) + if fetchErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) + continue + } + content = renderSelectedFilePreview(opts, selectedFile.Path, fileContent) + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + } + + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err) + } + fmt.Fprint(opts.IO.Out, content) + opts.IO.StopPager() + } +} + +func (opts *PreviewOptions) renderFile(filePath, content string) string { + if opts.RenderFile != nil { + return opts.RenderFile(filePath, content) + } + + return renderMarkdownPreview(opts.IO, filePath, content) +} + +func renderSelectedFilePreview(opts *PreviewOptions, filePath, content string) string { + if !isMarkdownFile(filePath) { + return content + } + + return opts.renderFile(filePath, content) +} + +func renderMarkdownPreview(io *iostreams.IOStreams, filePath, content string) string { + if filePath == "SKILL.md" { + parsed, err := frontmatter.Parse(content) + if err == nil { + content = parsed.Body + } + } + + rendered, err := markdown.Render(content, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth()), + markdown.WithoutIndentation()) + if err != nil { + return content + } + + return rendered +} + +func isMarkdownFile(filePath string) bool { + switch strings.ToLower(path.Ext(filePath)) { + case ".md", ".markdown", ".mdown", ".mkd", ".mkdn": + return true + default: + return false + } +} + +// filterHiddenDirSkills applies the --allow-hidden-dirs flag logic. When the +// flag is set, all skills are returned with a warning. Otherwise, hidden-dir +// skills are excluded with a hint or error. +func filterHiddenDirSkills(opts *PreviewOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) { + cs := opts.IO.ColorScheme() + + if opts.AllowHiddenDirs { + if discovery.HasHiddenDirSkills(allSkills) { + fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` + %[1]s Skills in hidden directories (e.g. .claude/, .agents/) may be installed + copies from another publisher. Verify the skill's origin and check for a + canonical source. + `, cs.WarningIcon())) + } + return allSkills, nil + } + + r := discovery.PartitionHiddenDirSkills(allSkills) + if r.HiddenCount > 0 { + if len(r.Standard) == 0 { + return nil, fmt.Errorf( + "no standard skills found, but %d skill(s) exist in hidden directories\n"+ + " Use --allow-hidden-dirs to include them", + r.HiddenCount, + ) + } + fmt.Fprintf(opts.IO.ErrOut, "%s %d skill(s) in hidden directories were excluded, use --%s to include them\n", + cs.Yellow("!"), r.HiddenCount, "allow-hidden-dirs") + } + + return r.Standard, nil +} + +func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { + if opts.SkillName != "" { + for _, s := range skills { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return s, nil + } + } + // Fall back to InstallName so that namespaced identifiers produced + // by the post-install hint (e.g. "namespace/skill") are accepted. + for _, s := range skills { + if s.InstallName() == opts.SkillName { + return s, nil + } + } + return discovery.Skill{}, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo)) + } + + if !opts.IO.CanPrompt() { + return discovery.Skill{}, fmt.Errorf("must specify a skill name when not running interactively") + } + + choices := make([]string, len(skills)) + for i, s := range skills { + choices[i] = s.DisplayName() + } + + idx, err := opts.Prompter.Select("Select a skill to preview:", "", choices) + if err != nil { + return discovery.Skill{}, err + } + + return skills[idx], nil +} + +// treeNode represents a file or directory in the tree for rendering. +type treeNode struct { + name string + children []*treeNode + isDir bool +} + +// renderFileTree prints a tree of skill files using box-drawing characters. +func renderFileTree(w io.Writer, cs *iostreams.ColorScheme, files []discovery.SkillFile) { + root := buildTree(files) + printTree(w, cs, root.children, "") +} + +// buildTree constructs a tree structure from flat file paths. +func buildTree(files []discovery.SkillFile) *treeNode { + root := &treeNode{isDir: true} + for _, f := range files { + parts := strings.Split(f.Path, "/") + current := root + for i, part := range parts { + isLast := i == len(parts)-1 + found := false + for _, child := range current.children { + if child.name == part { + current = child + found = true + break + } + } + if !found { + node := &treeNode{name: part, isDir: !isLast} + current.children = append(current.children, node) + current = node + } + } + } + sortTree(root) + return root +} + +func sortTree(node *treeNode) { + sort.Slice(node.children, func(i, j int) bool { + if node.children[i].isDir != node.children[j].isDir { + return node.children[i].isDir + } + return node.children[i].name < node.children[j].name + }) + for _, child := range node.children { + if child.isDir { + sortTree(child) + } + } +} + +func printTree(w io.Writer, cs *iostreams.ColorScheme, nodes []*treeNode, indent string) { + for i, node := range nodes { + isLast := i == len(nodes)-1 + connector := "├── " + childIndent := "│ " + if isLast { + connector = "└── " + childIndent = " " + } + if node.isDir { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(node.name+"/")) + printTree(w, cs, node.children, indent+cs.Muted(childIndent)) + } else { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), node.name) + } + } +} diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go new file mode 100644 index 00000000000..04fae62587e --- /dev/null +++ b/pkg/cmd/skills/preview/preview_test.go @@ -0,0 +1,1358 @@ +package preview + +import ( + "encoding/base64" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdPreview(t *testing.T) { + tests := []struct { + name string + input string + wantRepo string + wantSkillName string + wantVersion string + wantAllowHiddenDirs bool + wantErr bool + }{ + { + name: "repo and skill", + input: "github/awesome-copilot my-skill", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + }, + { + name: "repo and skill with version", + input: "github/awesome-copilot my-skill@v1.2.0", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantVersion: "v1.2.0", + }, + { + name: "repo and skill with SHA", + input: "github/awesome-copilot my-skill@abc123def456", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantVersion: "abc123def456", + }, + { + name: "repo only", + input: "github/awesome-copilot", + wantRepo: "github/awesome-copilot", + }, + { + name: "no args", + input: "", + wantErr: true, + }, + { + name: "too many args", + input: "a b c", + wantErr: true, + }, + { + name: "allow-hidden-dirs flag", + input: "github/awesome-copilot my-skill --allow-hidden-dirs", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantAllowHiddenDirs: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + } + + var gotOpts *PreviewOptions + cmd := NewCmdPreview(f, &telemetry.NoOpService{}, func(opts *PreviewOptions) error { + gotOpts = opts + return nil + }) + + args, _ := shlex.Split(tt.input) + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) + assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) + assert.Equal(t, tt.wantVersion, gotOpts.Version) + assert.Equal(t, tt.wantAllowHiddenDirs, gotOpts.AllowHiddenDirs) + }) + } +} + +func TestPreviewRun(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: A test skill + --- + # My Skill + + This is the skill content. + `) + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + tests := []struct { + name string + opts *PreviewOptions + tty bool + httpStubs func(*httpmock.Registry) + wantStdout string + wantErr string + }{ + { + name: "preview specific skill", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("github", "awesome-copilot"), + SkillName: "my-skill", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "preview with display name match", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "ns/my-skill", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/ns", "type": "tree", "sha": "tree-ns"}, + {"path": "skills/ns/my-skill", "type": "tree", "sha": "treeSHA2"}, + {"path": "skills/ns/my-skill/SKILL.md", "type": "blob", "sha": "blob456"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA2"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob456", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob456"), + httpmock.StringResponse(`{"sha": "blob456", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "preview plugins skill matched by install name", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "aws-common/aws-mcp-setup", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "plugins", "type": "tree", "sha": "tree-plugins"}, + {"path": "plugins/aws-common", "type": "tree", "sha": "tree-awscommon"}, + {"path": "plugins/aws-common/skills", "type": "tree", "sha": "tree-awsskills"}, + {"path": "plugins/aws-common/skills/aws-mcp-setup", "type": "tree", "sha": "treeSHA3"}, + {"path": "plugins/aws-common/skills/aws-mcp-setup/SKILL.md", "type": "blob", "sha": "blob789"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA3"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob789", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob789"), + httpmock.StringResponse(`{"sha": "blob789", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "preview by arbitrary nested skill path skips full discovery", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "packages/agent-skills/code-review", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/contents/packages%2Fagent-skills"), + httpmock.StringResponse(`[ + {"name": "code-review", "path": "packages/agent-skills/code-review", "sha": "treeSHA4", "type": "dir"} + ]`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA4"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob999", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob999"), + httpmock.StringResponse(`{"sha": "blob999", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "skill not found", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "nonexistent", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "tree2"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + }, + wantErr: `skill "nonexistent" not found in owner/repo`, + }, + { + name: "no skill name non-interactive errors", + tty: false, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "tree2"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + }, + wantErr: "must specify a skill name when not running interactively", + }, + { + name: "preview with explicit version", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("github", "awesome-copilot"), + SkillName: "my-skill", + Version: "abc123def456", + }, + httpStubs: func(reg *httpmock.Registry) { + // ResolveRef with explicit version tries branch first, then tag, then commit + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads/abc123def456"), + httpmock.StatusStringResponse(404, "not found"), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/abc123def456"), + httpmock.StatusStringResponse(404, "not found"), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/commits/abc123def456"), + httpmock.StringResponse(`{"sha": "abc123def456789012345678901234567890abcd"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123def456789012345678901234567890abcd"), + httpmock.StringResponse(`{ + "sha": "abc123def456789012345678901234567890abcd", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStdinTTY(tt.tty) + tt.opts.IO = ios + + tt.opts.Prompter = &prompter.PrompterMock{} + tt.opts.Telemetry = &telemetry.NoOpService{} + + err := previewRun(tt.opts) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + }) + } +} + +func TestPreviewRun_UnsupportedHost(t *testing.T) { + ios, _, _, _ := iostreams.Test() + err := previewRun(&PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), + Telemetry: &telemetry.NoOpService{}, + }) + require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") +} + +func TestPreviewRun_Interactive(t *testing.T) { + skillContent := "# Selected Skill\n\nContent here." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/alpha", "type": "tree", "sha": "tree-a"}, + {"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"}, + {"path": "skills/beta", "type": "tree", "sha": "tree-b"}, + {"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"), + httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + assert.Equal(t, "Select a skill to preview:", prompt) + assert.Equal(t, []string{"alpha", "beta"}, options) + return 1, nil // select "beta" + }, + } + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Selected Skill") +} + +func TestPreviewRun_ShowsFileTree(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + Body. + `) + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + scriptContent := "#!/bin/bash\necho hello" + encodedScript := base64.StdEncoding.EncodeToString([]byte(scriptContent)) + + makeReg := func() *httpmock.Registry { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}, + {"path": "skills/my-skill/scripts", "type": "tree", "sha": "treeScripts"}, + {"path": "skills/my-skill/scripts/run.sh", "type": "blob", "sha": "blobScript"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treeScripts"}, + {"path": "scripts/run.sh", "type": "blob", "sha": "blobScript", "size": 20} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobScript"), + httpmock.StringResponse(`{"sha": "blobScript", "content": "`+encodedScript+`", "encoding": "base64"}`), + ) + return reg + } + + t.Run("interactive file picker", func(t *testing.T) { + reg := makeReg() + defer reg.Verify(t) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetColorEnabled(false) + + selectCalls := 0 + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + selectCalls++ + if selectCalls == 1 { + // Options: ["SKILL.md", "scripts/run.sh"] + assert.Equal(t, "SKILL.md", options[0]) + assert.Equal(t, "scripts/run.sh", options[1]) + // Select "scripts/run.sh" + return 1, nil + } + // Simulate Esc/Ctrl-C to exit + return 0, fmt.Errorf("user cancelled") + }, + } + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "echo hello") + assert.Equal(t, 2, selectCalls) + }) + + t.Run("interactive markdown file uses markdown renderer", func(t *testing.T) { + readmeContent := "# Usage\n\nUse **carefully**." + encodedReadme := base64.StdEncoding.EncodeToString([]byte(readmeContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}, + {"path": "skills/my-skill/README.md", "type": "blob", "sha": "blobREADME"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}, + {"path": "README.md", "type": "blob", "sha": "blobREADME", "size": 28} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobREADME"), + httpmock.StringResponse(`{"sha": "blobREADME", "content": "`+encodedReadme+`", "encoding": "base64"}`), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetColorEnabled(false) + + renderCalls := 0 + + selectCalls := 0 + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + selectCalls++ + if selectCalls == 1 { + assert.Equal(t, []string{"SKILL.md", "README.md"}, options) + return 1, nil + } + return 0, fmt.Errorf("user cancelled") + }, + } + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + RenderFile: func(filePath, content string) string { + renderCalls++ + return fmt.Sprintf("rendered:%s", filePath) + }, + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "rendered:README.md") + assert.Equal(t, 2, selectCalls) + assert.Equal(t, 2, renderCalls) + }) + + t.Run("non-interactive dumps all files", func(t *testing.T) { + reg := makeReg() + defer reg.Verify(t) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + ios.SetColorEnabled(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "my-skill/") + assert.Contains(t, out, "My Skill") + assert.Contains(t, out, "scripts/run.sh") + assert.Contains(t, out, "echo hello") + }) +} + +func TestPreviewRun_RenderLimits(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + `) + encodedSkill := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + // Helper: build a tree JSON with N extra files (beyond SKILL.md) + buildTree := func(n int) string { + entries := []string{ + `{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}`, + `{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}`, + } + for i := range n { + entries = append(entries, fmt.Sprintf( + `{"path": "skills/my-skill/file%03d.txt", "type": "blob", "sha": "blob%03d"}`, i, i)) + } + return fmt.Sprintf(`{"sha":"abc123","truncated":false,"tree":[%s]}`, + strings.Join(entries, ",")) + } + + // Helper: build subtree JSON with N extra files + buildSubtree := func(n int, sizes []int) string { + entries := []string{ + `{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}`, + } + for i := range n { + sz := 10 + if i < len(sizes) { + sz = sizes[i] + } + entries = append(entries, fmt.Sprintf( + `{"path": "file%03d.txt", "type": "blob", "sha": "blob%03d", "size": %d}`, i, i, sz)) + } + return fmt.Sprintf(`{"tree":[%s]}`, strings.Join(entries, ",")) + } + + // Common stubs for resolve + discover + registerBase := func(reg *httpmock.Registry, treeJSON, subtreeJSON string) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/treeSHA"), + httpmock.StringResponse(subtreeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedSkill+`", "encoding": "base64"}`), + ) + } + + t.Run("maxFiles cap truncates at 20", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + n := 22 + treeJSON := buildTree(n) + subtreeJSON := buildSubtree(n, nil) + registerBase(reg, treeJSON, subtreeJSON) + + // Register blob stubs for files 0-19 (first 20 get fetched) + tinyContent := base64.StdEncoding.EncodeToString([]byte("tiny")) + for i := range 20 { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/skills-repo/git/blobs/blob%03d", i)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob%03d", "content": "%s", "encoding": "base64"}`, i, tinyContent)), + ) + } + // Files 20 and 21 should NOT be fetched + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "showing first 20") + assert.Contains(t, out, "file019.txt") // last fetched + }) + + t.Run("maxBytes cap stops fetching", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Two files: first is 500KB, second would exceed 512KB cap + sizes := []int{500 * 1024, 100 * 1024} + treeJSON := buildTree(2) + subtreeJSON := buildSubtree(2, sizes) + registerBase(reg, treeJSON, subtreeJSON) + + bigContent := base64.StdEncoding.EncodeToString(make([]byte, 500*1024)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)), + ) + // blob001 should NOT be fetched (size limit reached) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "size limit reached") + }) + + t.Run("blob fetch error shows fallback message", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + treeJSON := buildTree(1) + subtreeJSON := buildSubtree(1, nil) + registerBase(reg, treeJSON, subtreeJSON) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StatusStringResponse(500, "server error"), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "could not fetch file") + }) +} + +func TestPreviewRun_InteractiveTelemetryCapturesSelectedSkillName(t *testing.T) { + skillContent := "# Selected Skill\n\nContent here." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/alpha", "type": "tree", "sha": "tree-a"}, + {"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"}, + {"path": "skills/beta", "type": "tree", "sha": "tree-b"}, + {"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"), + httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + }), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 1, nil // select "beta" + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + Telemetry: recorder, + repo: ghrepo.New("owner", "repo"), + // SkillName intentionally left empty to simulate interactive selection + } + + err := previewRun(opts) + require.NoError(t, err) + + // Verify the telemetry event captured the interactively-selected skill name, not empty string + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_preview", event.Type) + assert.Equal(t, "beta", event.Dimensions["skill_name"], "telemetry should capture the selected skill name, not the empty opts.SkillName") +} + +func TestPreviewRun_TelemetryVisibility(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + Body. + `) + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + tests := []struct { + name string + visibility string + visibilityErr bool + wantSkillNames string + }{ + { + name: "public repo includes skill names", + visibility: "public", + wantSkillNames: "my-skill", + }, + { + name: "private repo excludes skill names", + visibility: "private", + }, + { + name: "internal repo excludes skill names", + visibility: "internal", + }, + { + name: "API error omits visibility and skill names", + visibilityErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + if tt.visibilityErr { + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.StatusStringResponse(500, "server error"), + ) + } else { + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": tt.visibility, + }), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + recorder := &telemetry.EventRecorderSpy{} + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + Telemetry: recorder, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_preview", event.Type) + + // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). + assert.Equal(t, "github.com", event.Dimensions["skill_host_type"]) + + if tt.visibilityErr { + assert.Equal(t, "unknown", event.Dimensions["repo_visibility"], + "visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch") + } else { + assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"]) + } + + // Owner, repo, and skill name are only included when the repo + // is public; for private/internal/unknown they are omitted to + // avoid leaking identifiers of non-public repositories. + if tt.wantSkillNames != "" { + assert.Equal(t, "owner", event.Dimensions["skill_owner"]) + assert.Equal(t, "repo", event.Dimensions["skill_repo"]) + assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_name"]) + } else { + assert.Empty(t, event.Dimensions["skill_owner"]) + assert.Empty(t, event.Dimensions["skill_repo"]) + assert.Empty(t, event.Dimensions["skill_name"]) + } + }) + } +} + +func TestFilterHiddenDirSkills(t *testing.T) { + standardSkill := discovery.Skill{Name: "my-skill", Convention: "standard"} + hiddenSkill := discovery.Skill{Name: "hidden-skill", Convention: "hidden-dir"} + hiddenNS := discovery.Skill{Name: "ns-skill", Convention: "hidden-dir-namespaced"} + + tests := []struct { + name string + allowHiddenDirs bool + skills []discovery.Skill + wantCount int + wantErr string + wantStderr string + }{ + { + name: "no hidden skills returns all", + skills: []discovery.Skill{standardSkill}, + wantCount: 1, + }, + { + name: "hidden skills excluded by default", + skills: []discovery.Skill{standardSkill, hiddenSkill}, + wantCount: 1, + wantStderr: "1 skill(s) in hidden directories were excluded", + }, + { + name: "multiple hidden skills excluded with hint", + skills: []discovery.Skill{standardSkill, hiddenSkill, hiddenNS}, + wantCount: 1, + wantStderr: "2 skill(s) in hidden directories were excluded", + }, + { + name: "only hidden skills returns error", + skills: []discovery.Skill{hiddenSkill, hiddenNS}, + wantErr: "no standard skills found, but 2 skill(s) exist in hidden directories", + }, + { + name: "allow-hidden-dirs includes all skills", + allowHiddenDirs: true, + skills: []discovery.Skill{standardSkill, hiddenSkill}, + wantCount: 2, + wantStderr: "Skills in hidden directories", + }, + { + name: "allow-hidden-dirs with no hidden skills", + allowHiddenDirs: true, + skills: []discovery.Skill{standardSkill}, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + opts := &PreviewOptions{ + IO: ios, + AllowHiddenDirs: tt.allowHiddenDirs, + } + + result, err := filterHiddenDirSkills(opts, tt.skills) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Len(t, result, tt.wantCount) + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + }) + } +} + +func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: A test skill + --- + # My Skill + + This is the skill content. + `) + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + // Tree contains both a standard skill and a hidden-dir skill + treeJSON := `{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}, + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "treeHidden"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blobHidden"} + ] + }` + + t.Run("hidden skills excluded by default with hint", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "My Skill") + assert.Contains(t, stderr.String(), "skill(s) in hidden directories were excluded") + assert.Contains(t, stderr.String(), "allow-hidden-dirs") + }) + + t.Run("allow-hidden-dirs includes hidden skills", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeHidden"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobHidden", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobHidden"), + httpmock.StringResponse(`{"sha": "blobHidden", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "hidden-skill", + AllowHiddenDirs: true, + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "My Skill") + assert.Contains(t, stderr.String(), "Skills in hidden directories") + assert.NotContains(t, stderr.String(), "were excluded") + }) + + t.Run("only hidden skills without flag returns error", func(t *testing.T) { + onlyHiddenTree := `{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "treeHidden"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blobHidden"} + ] + }` + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(onlyHiddenTree), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "hidden-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no standard skills found") + assert.Contains(t, err.Error(), "--allow-hidden-dirs") + }) +} diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go new file mode 100644 index 00000000000..c53ea0b6b72 --- /dev/null +++ b/pkg/cmd/skills/publish/publish.go @@ -0,0 +1,1133 @@ +package publish + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// PublishOptions holds all dependencies and user-provided flags for the publish command. +type PublishOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + GitClient *git.Client + + Dir string + Fix bool + DryRun bool + Tag string + + host string // resolved from config in production +} + +// publishDiagnostic is a single validation finding. +type publishDiagnostic struct { + skill string // empty for repo-level issues + severity string // "error", "warning", "fixed", or "info" + message string +} + +// repoTopicsResponse is the response from the repo topics API. +type repoTopicsResponse struct { + Names []string `json:"names"` +} + +// tagEntry is a single tag from the tags list API. +type tagEntry struct { + Name string `json:"name"` +} + +// rulesetsResponse is a single ruleset from the rulesets API. +type rulesetsResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Enforcement string `json:"enforcement"` +} + +// securityAnalysis represents the security_and_analysis field from the repo API. +type securityAnalysis struct { + AdvancedSecurity *securityFeature `json:"advanced_security"` + SecretScanning *securityFeature `json:"secret_scanning"` + SecretScanningPushProtection *securityFeature `json:"secret_scanning_push_protection"` +} + +type securityFeature struct { + Status string `json:"status"` +} + +// repoSecurityResponse is the subset of repo API we need for security checks. +type repoSecurityResponse struct { + SecurityAndAnalysis *securityAnalysis `json:"security_and_analysis"` +} + +// NewCmdPublish creates the "skills publish" command. +func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra.Command { + opts := &PublishOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + GitClient: f.GitClient, + } + + cmd := &cobra.Command{ + Use: "publish [] [flags]", + Short: "Validate and publish skills to a GitHub repository (preview)", + Long: heredoc.Docf(` + Validate a local repository's skills against the Agent Skills specification + and publish them by creating a GitHub release. + + Skills are discovered using the same conventions as install: + + - %[1]sskills/*/SKILL.md%[1]s + - %[1]sskills/{scope}/*/SKILL.md%[1]s + - %[1]s*/SKILL.md%[1]s (root-level) + - %[1]splugins/{scope}/skills/*/SKILL.md%[1]s + + Validation checks include: + + - Skill names match the strict agentskills.io naming rules + - Each skill name matches its directory name + - Required frontmatter fields (name, description) are present + - allowed-tools is a string, not an array + - Install metadata (%[1]smetadata.github-*%[1]s) is stripped if present + + After validation passes, publish will interactively guide you through: + + - Adding the %[1]sagent-skills%[1]s topic to the repository + - Choosing a version tag (semver recommended) + - Creating a GitHub release with auto-generated notes + + Use %[1]s--dry-run%[1]s to validate without publishing. + Use %[1]s--tag%[1]s to publish non-interactively with a specific tag. + Use %[1]s--fix%[1]s to automatically strip install metadata from committed files + without publishing. Review and commit the changes, then run publish again. + `, "`"), + Example: heredoc.Doc(` + # Validate and publish interactively + $ gh skill publish + + # Publish with a specific tag (non-interactive) + $ gh skill publish --tag v1.0.0 + + # Validate only (no publish) + $ gh skill publish --dry-run + + # Strip install metadata without publishing + $ gh skill publish --fix + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.Dir = args[0] + } + if err := cmdutil.MutuallyExclusive("specify only one of `--fix` or `--dry-run`", opts.Fix, opts.DryRun); err != nil { + return err + } + if runF != nil { + return runF(opts) + } + return publishRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible without publishing (e.g. strip install metadata)") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") + cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") + + return cmd +} + +func publishRun(opts *PublishOptions) error { + dir := opts.Dir + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return fmt.Errorf("could not determine working directory: %w", err) + } + } + + dir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + + canPrompt := opts.IO.CanPrompt() + + // Client initialization is deferred until after local validation so that + // simple errors (missing skills/, bad SKILL.md, etc.) are reported + // without requiring an HTTP client. + var client *api.Client + host := opts.host + + var diagnostics []publishDiagnostic + + skills, err := discovery.DiscoverLocalSkills(dir) + if err != nil { + return err + } + + for _, skill := range skills { + dirName := path.Base(skill.Path) + skillPath := filepath.Join(dir, filepath.FromSlash(skill.Path), "SKILL.md") + content, err := os.ReadFile(skillPath) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "missing SKILL.md file", + }) + continue + } + + result, err := frontmatter.Parse(string(content)) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("invalid frontmatter YAML: %s", err), + }) + continue + } + + // Validate name field exists + if result.Metadata.Name == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "missing required field: name", + }) + } else { + // Validate name matches directory + if result.Metadata.Name != dirName { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("name %q does not match directory name %q", result.Metadata.Name, dirName), + }) + } + + // Validate name is spec-compliant + if !discovery.IsSpecCompliant(result.Metadata.Name) { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("name %q does not follow agentskills.io naming convention (lowercase alphanumeric + hyphens)", result.Metadata.Name), + }) + } + } + + // Validate description field exists + if result.Metadata.Description == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "missing required field: description", + }) + } else if len(result.Metadata.Description) > 1024 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "warning", + message: fmt.Sprintf("description is %d chars (recommended max: 1024)", len(result.Metadata.Description)), + }) + } + + // Validate allowed-tools is string, not array + if raw, ok := result.RawYAML["allowed-tools"]; ok { + if _, isSlice := raw.([]interface{}); isSlice { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: "allowed-tools must be a string (space-delimited), not an array", + }) + } + } + + // Check for install metadata that should be stripped + if meta, ok := result.RawYAML["metadata"].(map[string]interface{}); ok { + githubKeys := findGitHubMetadataKeys(meta) + if len(githubKeys) > 0 { + if opts.Fix { + fixed, fixErr := stripGitHubMetadata(string(content)) + if fixErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("could not strip install metadata: %s", fixErr), + }) + } else if writeErr := os.WriteFile(skillPath, []byte(fixed), 0o644); writeErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("could not write fixed SKILL.md: %s", writeErr), + }) + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "fixed", + message: fmt.Sprintf("stripped install metadata: %s", strings.Join(githubKeys, ", ")), + }) + } + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "error", + message: fmt.Sprintf("contains install metadata that must be stripped: %s (use --fix)", strings.Join(githubKeys, ", ")), + }) + } + } + } + + // Recommended: license field + if result.Metadata.License == "" { + if _, ok := result.RawYAML["license"]; !ok { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "warning", + message: "recommended field missing: license", + }) + } + } + + // Recommended: body length + bodyLines := strings.Count(result.Body, "\n") + 1 + if bodyLines > 500 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: skill.DisplayName(), + severity: "warning", + message: fmt.Sprintf("skill body is %d lines (recommended max: 500 for efficient context)", bodyLines), + }) + } + } + + // Check for installed skill directories that should be gitignored + installedDirDiags := checkInstalledSkillDirs(opts.GitClient, dir) + diagnostics = append(diagnostics, installedDirDiags...) + + // Remote repository checks (best-effort) + repoInfo, remoteErr := detectGitHubRemote(opts.GitClient, dir) + if remoteErr != nil { + return remoteErr + } + owner, repo := "", "" + if repoInfo != nil { + owner = repoInfo.Repo.RepoOwner() + repo = repoInfo.Repo.RepoName() + } + + hasTopic := false + var existingTags []tagEntry + if owner != "" && repo != "" { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + client = api.NewClientFromHTTP(httpClient) + + if host == "" && repoInfo != nil { + host = repoInfo.Repo.RepoHost() + } + if host == "" { + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ = cfg.Authentication().DefaultHost() + } + if err := source.ValidateSupportedHost(host); err != nil { + return err + } + + // Security and ruleset checks (advisory, always shown) + var skillAbsDirs []string + for _, skill := range skills { + skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) + } + securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) + diagnostics = append(diagnostics, securityDiags...) + + rulesetDiags := checkTagProtection(client, host, owner, repo) + diagnostics = append(diagnostics, rulesetDiags...) + + // Check topic (needed for publish flow, not a blocking error) + hasTopic = repoHasTopic(client, host, owner, repo) + + // Fetch existing tags (needed for version suggestion) + existingTags = fetchTags(client, host, owner, repo) + } else { + diagnostics = append(diagnostics, detectMissingRepoDiagnostic(opts.GitClient, dir)...) + } + + // Render diagnostics + errors, warnings, fixes := 0, 0, 0 + for _, d := range diagnostics { + switch d.severity { + case "error": + errors++ + case "warning": + warnings++ + case "fixed": + fixes++ + } + } + + if canPrompt { + renderDiagnosticsTTY(opts, len(skills), diagnostics, errors, warnings, fixes, owner, repo) + } else { + renderDiagnosticsPlain(opts, diagnostics, errors, warnings) + } + + if errors > 0 { + return fmt.Errorf("validation failed with %d error(s)", errors) + } + + // --- Publish flow --- + if opts.DryRun { + fmt.Fprintf(opts.IO.ErrOut, "\nDry run complete. Use without --dry-run to publish.\n") + return nil + } + + if opts.Fix { + if fixes > 0 { + fmt.Fprintf(opts.IO.ErrOut, "\nFixed %d file(s). Review and commit the changes, then run %s to publish.\n", fixes, "gh skill publish") + } else { + fmt.Fprintf(opts.IO.ErrOut, "\nNo issues to fix.\n") + } + return nil + } + + if owner == "" || repo == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Set up a GitHub remote to publish.\n") + return nil + } + + if !canPrompt && opts.Tag == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Use --tag to publish non-interactively.\n") + return nil + } + + fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) + + return runPublishRelease(opts, client, host, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) +} + +// repoHasTopic checks whether the repo has the agent-skills topic. +func repoHasTopic(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + for _, t := range resp.Names { + if t == "agent-skills" { + return true + } + } + return false +} + +// fetchTags returns the most recent tags from the repo. +func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/tags?per_page=10", owner, repo) + var tags []tagEntry + if err := client.REST(host, "GET", apiPath, nil, &tags); err != nil { + return nil + } + return tags +} + +// runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. +func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir, remoteName string, hasTopic bool, existingTags []tagEntry) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + // Add topic if missing + if !hasTopic { + addTopic := true + if canPrompt { + var err error + addTopic, err = opts.Prompter.Confirm( + fmt.Sprintf("Add \"agent-skills\" topic to %s/%s? (required for discoverability)", owner, repo), true) + if err != nil { + return err + } + } + if addTopic { + if err := addAgentSkillsTopic(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not add topic: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Add it manually: gh repo edit %s/%s --add-topic agent-skills\n", owner, repo) + } else { + fmt.Fprintf(opts.IO.Out, "%s Added \"agent-skills\" topic\n", cs.SuccessIcon()) + } + } + } + + // Push unpushed commits (like gh pr create) + if err := ensurePushed(opts, dir, remoteName); err != nil { + return err + } + + // Determine tag + tag := opts.Tag + if tag == "" { + suggested := "v1.0.0" + if len(existingTags) > 0 { + if next := suggestNextTag(existingTags[0].Name); next != "" { + suggested = next + } + } + + if canPrompt { + strategies := []string{ + fmt.Sprintf("Semver (recommended): %s", suggested), + "Custom tag", + } + idx, err := opts.Prompter.Select("Tagging strategy:", "", strategies) + if err != nil { + return err + } + + if idx == 0 { + tag = suggested + edited, err := opts.Prompter.Input(fmt.Sprintf("Version tag [%s]:", suggested), suggested) + if err != nil { + return err + } + if edited != "" { + tag = edited + } + } else { + custom, err := opts.Prompter.Input("Tag:", "") + if err != nil { + return err + } + if custom == "" { + return fmt.Errorf("tag is required") + } + tag = custom + } + } else { + return fmt.Errorf("--tag is required for non-interactive publish") + } + } + + // Validate tag doesn't already exist + for _, t := range existingTags { + if t.Name == tag { + return fmt.Errorf("tag %s already exists; choose a different version", tag) + } + } + + // Offer to enable immutable releases + immutableEnabled := checkImmutableReleases(client, host, owner, repo) + if !immutableEnabled && canPrompt { + enableImmutable, err := opts.Prompter.Confirm( + "Enable immutable releases? (prevents tampering with published releases)", true) + if err != nil { + return err + } + if enableImmutable { + if err := enableImmutableReleases(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not enable immutable releases: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings > General > Releases\n") + } else { + fmt.Fprintf(opts.IO.Out, "%s Enabled immutable releases\n", cs.SuccessIcon()) + } + } + } + + // Inform if not on default branch + var currentBranch string + if opts.GitClient != nil { + branchGitClient := opts.GitClient.Copy() + branchGitClient.RepoDir = dir + if b, err := branchGitClient.CurrentBranch(context.Background()); err == nil { + currentBranch = b + } + } + defaultBranch := detectDefaultBranch(client, host, owner, repo) + if currentBranch != "" && defaultBranch != "" && currentBranch != defaultBranch { + fmt.Fprintf(opts.IO.ErrOut, "%s Publishing from branch %q (default is %q)\n", cs.WarningIcon(), currentBranch, defaultBranch) + } + + // Confirm and create release + if canPrompt { + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf("Create release %s with auto-generated notes?", tag), true) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintf(opts.IO.ErrOut, "Publish cancelled.\n") + return cmdutil.CancelError + } + } + + // Create release via REST API + releaseBody := map[string]interface{}{ + "tag_name": tag, + "generate_release_notes": true, + } + if currentBranch != "" { + releaseBody["target_commitish"] = currentBranch + } + releaseJSON, err := json.Marshal(releaseBody) + if err != nil { + return fmt.Errorf("failed to serialize release request: %w", err) + } + + releasePath := fmt.Sprintf("repos/%s/%s/releases", owner, repo) + var releaseResp struct { + HTMLURL string `json:"html_url"` + } + if err := client.REST(host, "POST", releasePath, bytes.NewReader(releaseJSON), &releaseResp); err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + fmt.Fprintf(opts.IO.Out, "%s Published %s\n", cs.SuccessIcon(), tag) + fmt.Fprintf(opts.IO.Out, "%s Install with: gh skill install %s/%s\n", cs.SuccessIcon(), owner, repo) + fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skill install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) + + return nil +} + +// ensurePushed checks whether the current branch has unpushed commits and +// pushes them automatically, consistent with how gh pr create behaves. +func ensurePushed(opts *PublishOptions, dir, remoteName string) error { + if opts.GitClient == nil { + return nil + } + + cs := opts.IO.ColorScheme() + gitClient := opts.GitClient.Copy() + gitClient.RepoDir = dir + + ctx := context.Background() + currentBranch, err := gitClient.CurrentBranch(ctx) + if err != nil { + return nil //nolint:nilerr // not on a branch (detached HEAD); skip push check + } + + // Count commits ahead of the push target (remote tracking branch). + // If the branch has no upstream, rev-list will fail; we treat that as + // "everything is unpushed" and push the whole branch. + unpushed := 0 + revCmd, err := gitClient.Command(ctx, "rev-list", "--count", "@{push}..HEAD") + if err != nil { + return fmt.Errorf("could not check unpushed commits: %w", err) + } + out, revErr := revCmd.Output() + if revErr != nil { + // @{push} not resolvable; branch has never been pushed + unpushed = -1 + } else { + n, parseErr := strconv.Atoi(strings.TrimSpace(string(out))) + if parseErr != nil { + return fmt.Errorf("could not parse unpushed commit count: %w", parseErr) + } + unpushed = n + } + + if unpushed == 0 { + return nil + } + + ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch) + fmt.Fprintf(opts.IO.ErrOut, "Pushing %s to %s...\n", currentBranch, remoteName) + if err := gitClient.Push(ctx, remoteName, ref); err != nil { + return fmt.Errorf("failed to push branch %s: %w", currentBranch, err) + } + fmt.Fprintf(opts.IO.ErrOut, "%s Pushed %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) + + return nil +} + +// detectDefaultBranch returns the default branch of the remote repo via the API. +func detectDefaultBranch(client *api.Client, host, owner, repo string) string { + if client == nil { + return "" + } + var result struct { + DefaultBranch string `json:"default_branch"` + } + if err := client.REST(host, "GET", fmt.Sprintf("repos/%s/%s", owner, repo), nil, &result); err != nil { + return "" + } + return result.DefaultBranch +} + +// addAgentSkillsTopic adds the "agent-skills" topic to the repo, preserving existing topics. +func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + + // Fetch existing topics + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return fmt.Errorf("could not fetch existing topics: %w", err) + } + + // Deduplicate: only add if not already present + for _, t := range resp.Names { + if t == "agent-skills" { + return nil + } + } + + topics := append(resp.Names, "agent-skills") + topicsJSON, err := json.Marshal(map[string][]string{"names": topics}) + if err != nil { + return fmt.Errorf("could not serialize topics: %w", err) + } + return client.REST(host, "PUT", apiPath, bytes.NewReader(topicsJSON), nil) +} + +// checkImmutableReleases checks if immutable releases are enabled for the repo. +func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + var resp struct { + Enabled bool `json:"enabled"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + return resp.Enabled +} + +// enableImmutableReleases enables immutable releases for the repo. +func enableImmutableReleases(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + body := bytes.NewReader([]byte(`{"enabled":true}`)) + return client.REST(host, "PATCH", apiPath, body, nil) +} + +// checkTagProtection checks whether tag protection rulesets are enabled. +func checkTagProtection(client *api.Client, host, owner, repo string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/rulesets", owner, repo) + var rulesets []rulesetsResponse + if err := client.REST(host, "GET", apiPath, nil, &rulesets); err != nil { + return nil + } + + for _, rs := range rulesets { + if rs.Target == "tag" && rs.Enforcement == "active" { + return nil + } + } + + return []publishDiagnostic{{ + severity: "warning", + message: "no active tag protection rulesets found. Consider protecting tags to ensure immutable releases (Settings > Rules > Rulesets)", + }} +} + +// checkSecuritySettings checks whether recommended security features are enabled. +func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDirs []string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var resp repoSecurityResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return nil + } + + if resp.SecurityAndAnalysis == nil { + return nil + } + + var diagnostics []publishDiagnostic + sa := resp.SecurityAndAnalysis + + if sa.SecretScanning == nil || sa.SecretScanning.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning is not enabled. Recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", + }) + } + + if sa.SecretScanningPushProtection == nil || sa.SecretScanningPushProtection.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning push protection is not enabled. Blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", + }) + } + + hasCode, hasManifests := detectCodeAndManifests(skillDirs) + + if hasCode { + alertsPath := fmt.Sprintf("repos/%s/%s/code-scanning/alerts?per_page=1&state=open", owner, repo) + if err := client.REST(host, "GET", alertsPath, nil, new([]interface{})); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", + }) + } + } + + if hasManifests { + dependabotPath := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", owner, repo) + if err := client.REST(host, "GET", dependabotPath, nil, nil); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", + }) + } + } + + return diagnostics +} + +// codeExtensions are file extensions that indicate code is present. +var codeExtensions = map[string]bool{ + ".go": true, ".py": true, ".js": true, ".ts": true, ".rb": true, + ".rs": true, ".java": true, ".cs": true, ".sh": true, ".bash": true, + ".zsh": true, ".ps1": true, ".swift": true, ".kt": true, ".c": true, + ".cpp": true, ".h": true, ".php": true, ".pl": true, ".lua": true, +} + +// manifestFiles are dependency manifest filenames. +var manifestFiles = map[string]bool{ + "package.json": true, "package-lock.json": true, "yarn.lock": true, + "go.mod": true, "go.sum": true, "Cargo.toml": true, "Cargo.lock": true, + "requirements.txt": true, "Pipfile": true, "Pipfile.lock": true, + "pyproject.toml": true, "poetry.lock": true, "Gemfile": true, + "Gemfile.lock": true, "pom.xml": true, "build.gradle": true, + "composer.json": true, "composer.lock": true, +} + +// detectCodeAndManifests walks the skill directories looking for code files +// and dependency manifests. +func detectCodeAndManifests(skillDirs []string) (hasCode, hasManifests bool) { + for _, dir := range skillDirs { + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(info.Name()) + if codeExtensions[ext] { + hasCode = true + } + if manifestFiles[info.Name()] { + hasManifests = true + } + if hasCode && hasManifests { + // Stop walking this skill directory early; the outer loop + // continues to process remaining skill directories. + return filepath.SkipAll + } + return nil + }) + if hasCode && hasManifests { + return + } + } + return +} + +// checkInstalledSkillDirs warns when agent host skill directories exist +// in the repo and are not gitignored. +func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDiagnostic { + var diagnostics []publishDiagnostic + + for _, relPath := range registry.UniqueProjectDirs() { + // Skip non-hidden project dirs (such as "skills") to avoid + // flagging the canonical authoring layout used when publishing. + if !strings.HasPrefix(relPath, ".") { + continue + } + absPath := filepath.Join(repoDir, relPath) + if _, err := os.Stat(absPath); os.IsNotExist(err) { + continue + } + + if gitClient != nil { + ignoreGitClient := gitClient.Copy() + ignoreGitClient.RepoDir = repoDir + ignored, err := ignoreGitClient.IsIgnored(context.Background(), relPath) + if ignored { + continue + } + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf("%s/ may contain installed skills that are not gitignored (could not verify: %v)", relPath, err), + }) + continue + } + } + + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf( + "%s/ contains installed skills and should be added to .gitignore to avoid publishing other authors' content", + relPath), + }) + } + + return diagnostics +} + +// semverPattern matches v-prefixed semver tags (e.g. v1.2.3). +var semverPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`) + +// suggestNextTag increments the patch version of a semver tag. +func suggestNextTag(latest string) string { + m := semverPattern.FindStringSubmatch(latest) + if m == nil { + return "" + } + + prefix := "" + if strings.HasPrefix(latest, "v") { + prefix = "v" + } + + major, minor := m[1], m[2] + patch := 0 + fmt.Sscanf(m[3], "%d", &patch) + + return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) +} + +// gitHubRemote holds a detected GitHub remote and its local name. +type gitHubRemote struct { + Repo ghrepo.Interface + RemoteName string +} + +// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes +// in the given directory. Remotes are tried in the order returned by +// gitClient.Remotes (upstream > github > origin > rest), so the first +// GitHub-pointing remote wins. +func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error) { + if gitClient == nil { + return nil, nil + } + + dirClient := gitClient.Copy() + dirClient.RepoDir = dir + + remotes, err := dirClient.Remotes(context.Background()) + if err != nil { + return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected + } + for _, r := range remotes { + if url, err := dirClient.RemoteURL(context.Background(), r.Name); err == nil { + repo, parseErr := parseGitHubURL(url) + if parseErr != nil { + return nil, parseErr + } + if repo != nil { + return &gitHubRemote{Repo: repo, RemoteName: r.Name}, nil + } + } + } + return nil, nil +} + +// parseGitHubURL extracts owner/repo from a GitHub remote URL. +// Only github.com and GHEC data residency (*.ghe.com) URLs are recognized. +func parseGitHubURL(rawURL string) (ghrepo.Interface, error) { + u, err := git.ParseURL(rawURL) + if err != nil { + return nil, nil //nolint:nilerr // unparseable URL means it's not a GitHub remote + } + r, err := ghrepo.FromURL(u) + if err != nil { + return nil, nil //nolint:nilerr // URL didn't match GitHub repo format + } + if err := source.ValidateSupportedHost(r.RepoHost()); err != nil { + return nil, nil //nolint:nilerr // non-GitHub host is silently ignored + } + return r, nil +} + +// detectMissingRepoDiagnostic explains why remote checks were skipped. +func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDiagnostic { + if gitClient == nil { + return nil + } + + dirGitClient := gitClient.Copy() + dirGitClient.RepoDir = dir + if _, err := dirGitClient.GitDir(context.Background()); err != nil { + return []publishDiagnostic{{ + severity: "warning", + message: "not a git repository. Initialize with: git init && gh repo create", + }} + } + + remotes, err := dirGitClient.Remotes(context.Background()) + if err != nil || len(remotes) == 0 { + return []publishDiagnostic{{ + severity: "warning", + message: "no git remote found. Create a GitHub repository with: gh repo create", + }} + } + + var urls []string + for _, r := range remotes { + if url, err := dirGitClient.RemoteURL(context.Background(), r.Name); err == nil { + urls = append(urls, url) + } + } + return []publishDiagnostic{{ + severity: "warning", + message: fmt.Sprintf("remote %q is not a GitHub repository. Skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), + }} +} + +func renderDiagnosticsTTY(opts *PublishOptions, skillCount int, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { + cs := opts.IO.ColorScheme() + + // Separate info messages from errors/warnings for cleaner output + var infos, issues []publishDiagnostic + for _, d := range diagnostics { + if d.severity == "info" { + infos = append(infos, d) + } else { + issues = append(issues, d) + } + } + + if len(issues) == 0 && fixes == 0 { + fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), skillCount) + } else { + for _, d := range issues { + var prefix string + switch d.severity { + case "error": + prefix = cs.FailureIcon() + case "warning": + prefix = cs.WarningIcon() + case "fixed": + prefix = cs.SuccessIcon() + default: + prefix = cs.FailureIcon() + } + if d.skill != "" { + fmt.Fprintf(opts.IO.Out, "%s %s: %s\n", prefix, cs.Bold(d.skill), d.message) + } else { + fmt.Fprintf(opts.IO.Out, "%s %s\n", prefix, d.message) + } + } + + fmt.Fprintln(opts.IO.Out) + if fixes > 0 { + fmt.Fprintf(opts.IO.Out, "Fixed %d issue(s)\n", fixes) + } + if errors > 0 { + fmt.Fprintf(opts.IO.Out, "%s, %s\n", + cs.Red(fmt.Sprintf("%d error(s)", errors)), + cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } else { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } + } + + // Always show info messages + for _, d := range infos { + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", d.message) + } + + if errors == 0 && !opts.Fix { + if owner != "" && repo != "" { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Repository: %s/%s\n", cs.Green("Ready to publish!"), owner, repo) + } else { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Ensure the repository has the \"agent-skills\" topic.\n", cs.Green("Ready to publish!")) + } + } +} + +func renderDiagnosticsPlain(opts *PublishOptions, diagnostics []publishDiagnostic, errors, warnings int) { + for _, d := range diagnostics { + if d.severity == "info" { + continue + } + fmt.Fprintf(opts.IO.Out, "%s\t%s\t%s\n", d.severity, d.skill, d.message) + } + if errors == 0 && warnings == 0 { + fmt.Fprintf(opts.IO.Out, "ok\n") + } +} + +// findGitHubMetadataKeys returns metadata keys with the "github-" prefix. +func findGitHubMetadataKeys(meta map[string]interface{}) []string { + var keys []string + for k := range meta { + if strings.HasPrefix(k, "github-") { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys +} + +// stripGitHubMetadata removes github-* keys from the metadata map and re-serializes. +func stripGitHubMetadata(content string) (string, error) { + result, err := frontmatter.Parse(content) + if err != nil { + return "", err + } + + meta, ok := result.RawYAML["metadata"].(map[string]interface{}) + if !ok { + return content, nil + } + + for k := range meta { + if strings.HasPrefix(k, "github-") { + delete(meta, k) + } + } + + if len(meta) == 0 { + delete(result.RawYAML, "metadata") + } else { + result.RawYAML["metadata"] = meta + } + + return frontmatter.Serialize(result.RawYAML, result.Body) +} diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go new file mode 100644 index 00000000000..757cc5126c2 --- /dev/null +++ b/pkg/cmd/skills/publish/publish_test.go @@ -0,0 +1,1667 @@ +package publish + +import ( + "bytes" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestGitClient returns a git.Client with a fake git path to avoid real git resolution. +func newTestGitClient() *git.Client { + return &git.Client{GitPath: "some/path/git"} +} + +// stubGitRemote registers CommandStubber stubs for git remote detection. +func stubGitRemote(cs *run.CommandStubber, remoteURLs map[string]string) { + var remoteLines string + for name, url := range remoteURLs { + remoteLines += fmt.Sprintf("%[1]s\t%[2]s (fetch)\n%[1]s\t%[2]s (push)\n", name, url) + } + cs.Register(`git( .+)? remote -v`, 0, remoteLines) + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + for name, url := range remoteURLs { + cs.Register(fmt.Sprintf(`git( .+)? remote get-url -- %s`, regexp.QuoteMeta(name)), 0, url+"\n") + } +} + +// stubEnsurePushed registers stubs for ensurePushed + runPublishRelease CurrentBranch calls. +func stubEnsurePushed(cs *run.CommandStubber, branch string) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/"+branch+"\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "0\n") + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/"+branch+"\n") +} + +// stubAllSecureRemote registers the standard stubs for a fully-configured remote +// repo (topics, tags, rulesets, security) so publishRun skips all remote warnings. +func stubAllSecureRemote(reg *httpmock.Registry, owner, repo string) { + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.0.0"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/"+owner+"/"+repo), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) +} + +func TestNewCmdPublish(t *testing.T) { + tests := []struct { + name string + cli string + wantsErr bool + wantsOpts PublishOptions + }{ + { + name: "fix and dry-run are mutually exclusive", + cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + wantsErr: true, + }, + { + name: "fix flag only", + cli: "--fix", + wantsOpts: PublishOptions{ + Fix: true, + }, + }, + { + name: "directory only", + cli: "./octocat-repo", + wantsOpts: PublishOptions{ + Dir: "./octocat-repo", + }, + }, + { + name: "no args leaves dir empty", + cli: "", + wantsOpts: PublishOptions{}, + }, + { + name: "dry-run flag only", + cli: "--dry-run", + wantsOpts: PublishOptions{ + DryRun: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := cmdutil.Factory{IOStreams: ios} + + var gotOpts *PublishOptions + cmd := NewCmdPublish(&f, func(opts *PublishOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantsOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantsOpts.DryRun, gotOpts.DryRun) + assert.Equal(t, tt.wantsOpts.Fix, gotOpts.Fix) + assert.Equal(t, tt.wantsOpts.Tag, gotOpts.Tag) + }) + } +} + +func TestPublishRun_UnsupportedHost(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + Body. + `)) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + stubGitRemote(cs, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) + + ios, _, _, _ := iostreams.Test() + err := publishRun(&PublishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return nil, nil }, + host: "acme.ghes.com", + }) + require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") +} + +func TestPublishRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, dir string) + stubs func(*httpmock.Registry) + cmdStubs func(*run.CommandStubber) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions + verify func(t *testing.T, dir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "no skills found", + setup: func(_ *testing.T, _ string) {}, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "no skills found", + }, + { + name: "empty skills directory has no discoverable skills", + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "no skills found", + }, + { + name: "missing name in frontmatter", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + description: A skill for writing good git commits + --- + Body text. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "missing required field: name", + }, + { + name: "name does not match directory", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: wrong-name + description: A skill + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "does not match directory name", + }, + { + name: "non-spec-compliant name", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "My_Skill", heredoc.Doc(` + --- + name: My_Skill + description: A skill with non-compliant name + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "naming convention", + }, + { + name: "root-level skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a root-level skill (*/SKILL.md convention) + skillDir := filepath.Join(dir, "my-root-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: my-root-skill + description: A root-level skill + license: MIT + --- + Body. + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "namespaced skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a namespaced skill (skills/{scope}/*/SKILL.md convention) + skillDir := filepath.Join(dir, "skills", "monalisa", "scoped-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: scoped-skill + description: A namespaced skill + license: MIT + --- + Body. + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "valid skill dry-run passes validation", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "good-skill", heredoc.Doc(` + --- + name: good-skill + description: A good skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "valid skill with --tag publishes release", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // topic already present, so no PUT needed + // immutable releases check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch for branch comparison + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.1", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "Published v1.0.1", + }, + { + name: "strip metadata with --fix", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-repo: something + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: def456 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir, Fix: true} + }, + wantStdout: "stripped install metadata", + wantStderr: "Fixed 1 file(s). Review and commit the changes", + verify: func(t *testing.T, dir string) { + t.Helper() + fixed, err := os.ReadFile(filepath.Join(dir, "skills", "test-skill", "SKILL.md")) + require.NoError(t, err) + fixedStr := string(fixed) + assert.NotContains(t, fixedStr, "github-owner") + assert.NotContains(t, fixedStr, "github-sha") + assert.NotContains(t, fixedStr, "metadata:") + }, + }, + { + name: "metadata without --fix errors with hint", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-sha: abc123 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir, Fix: false} + }, + wantErr: "validation failed", + wantStdout: "--fix", + }, + { + name: "missing license warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "no-license", heredoc.Doc(` + --- + name: no-license + description: A skill without license + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantStdout: "license", + }, + { + name: "allowed-tools array error", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "bad-tools", heredoc.Doc(` + --- + name: bad-tools + description: A skill with array allowed-tools + allowed-tools: + - git + - curl + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "allowed-tools must be a string", + }, + { + name: "security warnings when features disabled", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "branch-only", "target": "branch", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "disabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + }, + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/octocat/secure-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "secret scanning is not enabled", + }, + { + name: "tag protection warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/rulesets"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/octocat/tag-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "tag protection", + }, + { + name: "code files trigger code scanning info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "code-skill", heredoc.Doc(` + --- + name: code-skill + description: A skill with code + license: MIT + --- + Body. + `)) + scriptDir := filepath.Join(dir, "skills", "code-skill", "scripts") + require.NoError(t, os.MkdirAll(scriptDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/code-scanning/alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/octocat/code-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStderr: "code scanning", + }, + { + name: "manifest files trigger dependabot info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "dep-skill", heredoc.Doc(` + --- + name: dep-skill + description: A skill with manifests + license: MIT + --- + Body. + `)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "skills", "dep-skill", "package.json"), + []byte("{}"), 0o644, + )) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/vulnerability-alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/octocat/dep-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStderr: "Dependabot", + }, + { + name: "installed skill dirs not gitignored warns", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? check-ignore -q -- .agents/skills`, 1, "") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, + } + }, + wantStdout: "may contain installed skills that are not gitignored", + }, + { + name: "installed skill dirs gitignored no warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? check-ignore -q -- .agents/skills`, 0, "") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, + } + }, + wantStdout: "no git remote", + verify: func(t *testing.T, dir string) { + t.Helper() + // The key assertion: .gitignored dirs should NOT produce a warning + }, + }, + { + name: "installed skill dirs git error warns about unverified status", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? check-ignore -q -- .agents/skills`, 128, "") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, + } + }, + wantStdout: "may contain installed skills that are not gitignored", + }, + { + name: "no GitHub remote warns", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + }) + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "origin\thttps://gitlab.com/hubot/bar.git (fetch)\norigin\thttps://gitlab.com/hubot/bar.git (push)\n") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(fmt.Sprintf(`git( .+)? remote get-url -- %s`, regexp.QuoteMeta("origin")), 0, "https://gitlab.com/hubot/bar.git\n") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, + } + }, + wantStdout: "not a GitHub repository", + }, + { + name: "fallback remote detection uses non-origin GitHub remote", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "octocat", "repo") + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? remote -v`, 0, "origin\thttps://gitlab.com/hubot/bar.git (fetch)\norigin\thttps://gitlab.com/hubot/bar.git (push)\nupstream\tgit@github.com:octocat/repo.git (fetch)\nupstream\tgit@github.com:octocat/repo.git (push)\n") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + // upstream sorts first (score 3 > 1), so only upstream's get-url is called + cs.Register(fmt.Sprintf(`git( .+)? remote get-url -- %s`, regexp.QuoteMeta("upstream")), 0, "git@github.com:octocat/repo.git\n") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStderr: "octocat/repo", + }, + { + name: "publish adds missing topic via --tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // topic missing + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // addAgentSkillsTopic fetches topics again then PUTs + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{}), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + }, + { + name: "tag suggestion uses existing tags", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v2.3.4"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release with the suggested v2.3.5 tag + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v2.3.5", + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "Published v2.3.5", + }, + { + name: "duplicate tag errors", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/main\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "0\n") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantErr: "tag v1.0.0 already exists", + }, + { + name: "valid skill non-tty plain output", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "ok", + }, + { + name: "no remote and non-tty shows validation passed message", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + } + }, + wantStdout: "ok", + }, + { + name: "interactive publish with topic and semver tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // No topic yet, first GET for diagnostic check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Second GET inside addAgentSkillsTopic + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Add topic + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "default_branch": "main", + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // Immutable releases already enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // Create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + confirmCall := 0 + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + return true, nil // accept topic + final confirm + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil // semver strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.0", nil // accept suggested tag + }, + }, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "Published v1.0.0", + }, + { + name: "interactive publish with custom tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/beta-1", + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 1, nil // custom tag strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "beta-1", nil + }, + }, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "Published beta-1", + }, + { + name: "interactive publish declined at final confirm", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + confirmCall := 0 + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + if confirmCall >= 1 { + return false, nil // decline final confirm + } + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantErr: "CancelError", + wantStderr: "Publish cancelled", + }, + { + name: "interactive immutable releases prompt", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // Immutable releases NOT enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": false}), + ) + // Enable immutable releases + reg.Register( + httpmock.REST("PATCH", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil // accept all confirms (immutable + final) + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + } + }, + wantStdout: "Enabled immutable releases", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + if tt.cmdStubs != nil { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + tt.cmdStubs(cs) + } + + opts := tt.opts(ios, dir, reg) + err := publishRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) + } +} + +func TestDetectGitHubRemote_UsesDir(t *testing.T) { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + cwdRepo := t.TempDir() + targetRepo := t.TempDir() + + gitClient := &git.Client{GitPath: "some/path/git", RepoDir: cwdRepo} + + repo, err := detectGitHubRemote(gitClient, targetRepo) + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, "monalisa", repo.Repo.RepoOwner()) + assert.Equal(t, "target-repo", repo.Repo.RepoName()) +} + +func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + cwdRepo := t.TempDir() + targetRepo := t.TempDir() + + writeSkill(t, targetRepo, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A test skill + license: MIT + --- + Body text. + `)) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + stubAllSecureRemote(reg, "monalisa", "target-repo") + + err := publishRun(&PublishOptions{ + IO: ios, + Dir: targetRepo, + DryRun: true, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: cwdRepo}, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + host: "github.com", + }) + + require.NoError(t, err) + assert.Contains(t, stdout.String(), "1 skill(s) validated successfully") +} + +// writeSkill creates skills//SKILL.md with the given content. +func writeSkill(t *testing.T, dir, name, content string) { + t.Helper() + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +func TestEnsurePushed(t *testing.T) { + tests := []struct { + name string + cmdStubs func(*run.CommandStubber) + wantErr string + wantStderr string + }{ + { + name: "no unpushed commits is a no-op", + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/main\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "0\n") + }, + }, + { + name: "unpushed commits are pushed automatically", + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/main\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "1\n") + cs.Register(`git( .+)? push --set-upstream origin HEAD:refs/heads/main`, 0, "") + }, + wantStderr: "Pushing main to origin", + }, + { + name: "new branch that has not been pushed is pushed automatically", + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/feature\n") + // rev-list fails when branch is not pushed + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 1, "") + cs.Register(`git( .+)? push --set-upstream origin HEAD:refs/heads/feature`, 0, "") + }, + wantStderr: "Pushing feature to origin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + tt.cmdStubs(cs) + + workDir := t.TempDir() + + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + opts := &PublishOptions{ + IO: ios, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: workDir}, + } + + err := ensurePushed(opts, workDir, "origin") + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + }) + } +} diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go new file mode 100644 index 00000000000..5f510aae2d2 --- /dev/null +++ b/pkg/cmd/skills/search/search.go @@ -0,0 +1,936 @@ +package search + +import ( + "errors" + "fmt" + "math" + "net/http" + "net/url" + "os" + "os/exec" + "sort" + "strings" + "sync" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + defaultLimit = 15 + maxResults = 1000 // GitHub Code Search API hard limit + + // searchPageSize is the number of raw results to request from the + // GitHub Search API per call (max allowed). + searchPageSize = 100 +) + +// SkillSearchFields defines the set of fields available for --json output. +var SkillSearchFields = []string{ + "repo", + "skillName", + "namespace", + "description", + "stars", + "path", +} + +type SearchOptions struct { + IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + ExecutablePath string // path to the current gh binary for install subprocess + Exporter cmdutil.Exporter + + // User inputs + Query string + Owner string // optional: scope results to a specific GitHub owner + Page int + Limit int +} + +// NewCmdSearch creates the "skills search" command. +func NewCmdSearch(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*SearchOptions) error) *cobra.Command { + opts := &SearchOptions{ + IO: f.IOStreams, + Telemetry: telemetry, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + ExecutablePath: f.ExecutablePath, + } + + cmd := &cobra.Command{ + Use: "search [flags]", + Short: "Search for skills across GitHub (preview)", + Long: heredoc.Docf(` + Search across all public GitHub repositories for skills matching a keyword. + + Uses the GitHub Code Search API to find %[1]sSKILL.md%[1]s files whose name or + description matches the query term. + + Results are ranked by relevance: skills whose name contains the query + term appear first. + + Use %[1]s--owner%[1]s to scope results to a specific GitHub user or organization. + + In interactive mode, you can select skills from the results to install directly. + `, "`"), + Example: heredoc.Doc(` + # Search for skills related to terraform + $ gh skill search terraform + + # Search for skills from a specific owner + $ gh skill search terraform --owner hashicorp + + # View the second page of results + $ gh skill search terraform --page 2 + + # Limit results to 5 + $ gh skill search terraform --limit 5 + `), + Args: cmdutil.MinimumArgs(1, "cannot search: query argument required"), + RunE: func(c *cobra.Command, args []string) error { + opts.Query = strings.Join(args, " ") + + if len(strings.TrimSpace(opts.Query)) < 2 { + return cmdutil.FlagErrorf("search query must be at least 2 characters") + } + + if opts.Page < 1 { + return cmdutil.FlagErrorf("invalid page number: %d", opts.Page) + } + + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit) + } + + opts.Owner = strings.TrimSpace(opts.Owner) + if opts.Owner != "" && !couldBeOwner(opts.Owner) { + return cmdutil.FlagErrorf("invalid owner %q: must be a valid GitHub username or organization", opts.Owner) + } + + if runF != nil { + return runF(opts) + } + return searchRun(opts) + }, + } + + cmd.Flags().IntVar(&opts.Page, "page", 1, "Page number of results to fetch") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of results per page") + cmd.Flags().StringVar(&opts.Owner, "owner", "", "Filter results to a specific GitHub user or organization") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, SkillSearchFields) + + return cmd +} + +// codeSearchResult represents the GitHub Code Search API response. +type codeSearchResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []codeSearchItem `json:"items"` +} + +// codeSearchItem represents a single code search hit. +type codeSearchItem struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Repository codeSearchRepository `json:"repository"` +} + +// codeSearchRepository is the repo info embedded in a code search hit. +type codeSearchRepository struct { + FullName string `json:"full_name"` +} + +// skillResult is a deduplicated search result. +type skillResult struct { + Repo string + Owner string // parsed from Repo + RepoName string // parsed from Repo + SkillName string + Namespace string // namespace prefix: author/scope for skills/{author}/* or plugin name for plugins/{plugin}/skills/* + Description string + Path string // original file path (e.g. skills/terraform/SKILL.md) + BlobSHA string + Stars int // repository stargazer count +} + +// qualifiedName returns the namespace-qualified skill name (e.g. "author/skill") +// or just the skill name if there is no namespace. +func (s skillResult) qualifiedName() string { + if s.Namespace != "" { + return s.Namespace + "/" + s.SkillName + } + return s.SkillName +} + +// ExportData implements cmdutil.exportable for --json output. +func (s skillResult) ExportData(fields []string) map[string]interface{} { + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "repo": + data[f] = s.Repo + case "skillName": + data[f] = s.SkillName + case "namespace": + data[f] = s.Namespace + case "description": + data[f] = s.Description + case "stars": + data[f] = s.Stars + case "path": + data[f] = s.Path + } + } + return data +} + +func searchRun(opts *SearchOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ := cfg.Authentication().DefaultHost() + if err := source.ValidateSupportedHost(host); err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Searching for skills") + + skills, err := searchByKeyword(apiClient, host, opts.Query, opts.Owner, opts.Page, opts.Limit) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + if len(skills) == 0 { + opts.IO.StopProgressIndicator() + return noResults(opts, noResultsMessage(opts)) + } + + // Pre-rank before expensive enrichment, then truncate working set. + rankByRelevance(skills, opts.Query) + skills = truncateForProcessing(skills, opts.Page, opts.Limit) + + enrichSkills(apiClient, host, skills) + opts.IO.StopProgressIndicator() + + // Filter out noise and re-rank with enriched data (descriptions, stars). + skills = filterByRelevance(skills, opts.Query) + if len(skills) == 0 { + return noResults(opts, noResultsMessage(opts)) + } + rankByRelevance(skills, opts.Query) + + // Collapse duplicate skill names across repos, keeping up to 3 + // top-ranked instances of each. Prevents aggregator repos + // (which copy popular skills) from flooding results. + skills = deduplicateByName(skills) + + // Paginate to the requested page window. + var totalPages int + skills, totalPages = paginate(skills, opts.Page, opts.Limit) + if len(skills) == 0 { + msg := fmt.Sprintf("no skills found on page %d for query %q", opts.Page, opts.Query) + if opts.Owner != "" { + msg = fmt.Sprintf("no skills found on page %d for query %q from owner %q", opts.Page, opts.Query, opts.Owner) + } + return noResults(opts, msg) + } + + return renderResults(opts, skills, totalPages) +} + +// noResultsMessage returns an appropriate "no results" message. +func noResultsMessage(opts *SearchOptions) string { + if opts.Owner != "" { + return fmt.Sprintf("no skills found matching %q from owner %q", opts.Query, opts.Owner) + } + return fmt.Sprintf("no skills found matching %q", opts.Query) +} + +// searchByKeyword runs parallel searches: content match, path match, owner +// match (for single-word queries), and (for multi-word queries) a hyphenated +// content match to catch skill names like "mcp-apps" when the user types +// "mcp apps". When owner is non-empty, all queries are scoped to that +// GitHub user/org via user: and the implicit owner search is skipped. +func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, limit int) ([]skillResult, error) { + ownerScope := "" + if owner != "" { + ownerScope = " user:" + owner + } + + primaryQ := fmt.Sprintf("filename:SKILL.md %s%s", queryTerm, ownerScope) + pathTerm := strings.ReplaceAll(queryTerm, " ", "-") + pathQ := fmt.Sprintf("filename:SKILL.md path:%s%s", pathTerm, ownerScope) + + var ( + primaryItems []codeSearchItem + primaryErr error + pathResult *codeSearchResult + pathErr error + ownerResult *codeSearchResult + ownerErr error + hyphenResult *codeSearchResult + hyphenErr error + ) + + hasSpaces := strings.Contains(queryTerm, " ") + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + pathResult, pathErr = executeSearch(client, host, pathQ, 1, searchPageSize) + }() + + // When no explicit --owner is set and the query looks like it could be a + // GitHub username, fire an additional user: search to discover + // skills published by that org. Results compete on the same footing as + // everything else (no scoring boost). + if owner == "" && couldBeOwner(queryTerm) { + ownerQ := fmt.Sprintf("filename:SKILL.md user:%s", queryTerm) + wg.Add(1) + go func() { + defer wg.Done() + ownerResult, ownerErr = executeSearch(client, host, ownerQ, 1, searchPageSize) + }() + } + + // When the query has spaces (e.g. "mcp apps"), run an additional content + // search with the hyphenated form ("mcp-apps") so we don't miss skills + // whose names use hyphens as word separators. + if hasSpaces { + hyphenQ := fmt.Sprintf("filename:SKILL.md %s%s", pathTerm, ownerScope) + wg.Add(1) + go func() { + defer wg.Done() + hyphenResult, hyphenErr = executeSearch(client, host, hyphenQ, 1, searchPageSize) + }() + } + + // Primary content search runs on the main goroutine. + primaryItems, _, primaryErr = fetchPrimaryPages(client, host, primaryQ, page, limit) + wg.Wait() + + if primaryErr != nil { + return nil, primaryErr + } + + // Merge: path-matched > hyphen-matched > owner-matched > primary content. + var merged []codeSearchItem + + if pathErr == nil && pathResult != nil { + merged = append(merged, pathResult.Items...) + } + if hasSpaces && hyphenErr == nil && hyphenResult != nil { + merged = append(merged, hyphenResult.Items...) + } + if ownerErr == nil && ownerResult != nil { + merged = append(merged, ownerResult.Items...) + } + merged = append(merged, primaryItems...) + + return deduplicateResults(merged), nil +} + +// noResults returns an empty JSON array for exporters or a no-results error. +func noResults(opts *SearchOptions, msg string) error { + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, []skillResult{}) + } + return cmdutil.NewNoResultsError(msg) +} + +// truncateForProcessing caps the working set before expensive enrichment. +// Each skill in the working set triggers a blob fetch (description) and +// potentially a repo fetch (stars), so keeping this small matters for +// performance. Pre-ranking ensures the best candidates are at the top. +func truncateForProcessing(skills []skillResult, page, limit int) []skillResult { + maxToProcess := page * limit * 3 + if maxToProcess < limit*3 { + maxToProcess = limit * 3 + } + if len(skills) > maxToProcess { + return skills[:maxToProcess] + } + return skills +} + +// enrichSkills fetches descriptions and star counts concurrently. +// Each function collects results into a map; merges happen after both complete +// to avoid concurrent writes to the shared skills slice. +func enrichSkills(client *api.Client, host string, skills []skillResult) { + var descMap map[int]string + var starsMap map[int]int + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + descMap = fetchDescriptions(client, host, skills) + }() + go func() { + defer wg.Done() + starsMap = fetchRepoStars(client, host, skills) + }() + wg.Wait() + + for i := range skills { + if desc, ok := descMap[i]; ok { + skills[i].Description = desc + } + if stars, ok := starsMap[i]; ok { + skills[i].Stars = stars + } + } +} + +// paginate slices results to the requested page window. +func paginate(skills []skillResult, page, limit int) ([]skillResult, int) { + total := len(skills) + totalPages := (total + limit - 1) / limit + start := (page - 1) * limit + if start >= total { + return nil, totalPages + } + end := start + limit + if end > total { + end = total + } + return skills[start:end], totalPages +} + +// deduplicateByName caps the number of results with the same qualified skill +// name. Since results are pre-sorted by relevance score, the first occurrences +// are the best instances. This prevents aggregator repos (which copy +// popular skills verbatim) from flooding results while still showing +// a few alternative sources. Namespaced skills (e.g. "author/skill") are +// treated as distinct from bare names. +func deduplicateByName(skills []skillResult) []skillResult { + const maxPerName = 3 + counts := make(map[string]int) + var result []skillResult + for _, s := range skills { + key := strings.ToLower(s.qualifiedName()) + if counts[key] >= maxPerName { + continue + } + counts[key]++ + result = append(result, s) + } + return result +} + +// renderResults handles all output modes: JSON, interactive picker, or table. +func renderResults(opts *SearchOptions, skills []skillResult, totalPages int) error { + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, skills) + } + + cs := opts.IO.ColorScheme() + header := fmt.Sprintf("\n%s Showing %s matching %q", + cs.SuccessIcon(), + text.Pluralize(len(skills), "skill"), + opts.Query, + ) + if totalPages > 1 { + header += fmt.Sprintf(" (page %d/%d)", opts.Page, totalPages) + } + + if opts.IO.CanPrompt() { + fmt.Fprintln(opts.IO.ErrOut, header) + if opts.Page < totalPages { + fmt.Fprintf(opts.IO.ErrOut, "Use --page %d for more results.\n", opts.Page+1) + } + return promptInstall(opts, skills) + } + + // Non-interactive mode: render table. + if opts.IO.IsStdoutTTY() { + fmt.Fprintln(opts.IO.Out, header) + fmt.Fprintln(opts.IO.Out) + } + + if err := renderTable(opts.IO, skills); err != nil { + return err + } + + if opts.IO.IsStdoutTTY() && opts.Page < totalPages { + fmt.Fprintf(opts.IO.ErrOut, "\nUse --page %d for more results.\n", opts.Page+1) + } + + return nil +} + +// renderTable outputs a formatted table of skill results. +func renderTable(io *iostreams.IOStreams, skills []skillResult) error { + isTTY := io.IsStdoutTTY() + tw := io.TerminalWidth() + descWidth := tw - 70 + if descWidth < 20 { + descWidth = 20 + } + + table := tableprinter.New(io, tableprinter.WithHeader("REPOSITORY", "SKILL", "DESCRIPTION", "STARS")) + for _, s := range skills { + table.AddField(s.Repo) + table.AddField(s.qualifiedName()) + desc := s.Description + if isTTY { + desc = text.Truncate(descWidth, desc) + } + table.AddField(desc) + table.AddField(formatStars(s.Stars)) + table.EndRow() + } + return table.Render() +} + +// promptInstall shows a multi-select picker for the user to choose skills +// to install from the search results, then runs the install command for each. +func promptInstall(opts *SearchOptions, skills []skillResult) error { + fmt.Fprintln(opts.IO.ErrOut) + + cs := opts.IO.ColorScheme() + + // Reserve space for the checkbox UI prefix ("[ ] ") and the description + // indent ("\n " = 7 chars), then use the remaining terminal width. + tw := opts.IO.TerminalWidth() + descWidth := tw - 11 + if descWidth < 30 { + descWidth = 30 + } + + options := make([]string, len(skills)) + for i, s := range skills { + starStr := "" + if s.Stars > 0 { + starStr = " " + cs.Muted("★ "+formatStars(s.Stars)) + } + descStr := "" + if s.Description != "" { + desc := strings.Join(strings.Fields(s.Description), " ") + descStr = "\n " + cs.Muted(text.Truncate(descWidth, desc)) + } + options[i] = s.qualifiedName() + " " + cs.Muted(s.Repo) + starStr + descStr + } + + indices, err := opts.Prompter.MultiSelect( + "Select skills to install:", + nil, + options, + ) + if err != nil { + return err + } + + if len(indices) == 0 { + return nil + } + + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_search_install", + Measures: ghtelemetry.Measures{ + "install_count": int64(len(indices)), + }, + }) + + // Prompt for target agent host (once for all selected skills) + hostNames := registry.AgentNames() + hostIdx, err := opts.Prompter.Select("Select target agent:", "", hostNames) + if err != nil { + return err + } + host := registry.Agents[hostIdx] + + // Prompt for installation scope + scopeIdx, err := opts.Prompter.Select("Installation scope:", "", registry.ScopeLabels("")) + if err != nil { + return err + } + scope := string(registry.ScopeProject) + if scopeIdx == 1 { + scope = string(registry.ScopeUser) + } + + for _, idx := range indices { + s := skills[idx] + displayName := s.qualifiedName() + fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", + cs.Blue("::"), displayName, s.Repo) + + // Use the repo-relative directory path (e.g. "skills/author/name") + // for disambiguation when installing namespaced skills, so the + // install command can resolve the exact skill without ambiguity. + installArg := s.SkillName + if s.Namespace != "" { + installArg = strings.TrimSuffix(s.Path, "/SKILL.md") + } + + //nolint:gosec // arguments are from user-selected search results, not arbitrary input + cmd := exec.Command(opts.ExecutablePath, "skills", "install", s.Repo, installArg, + "--agent", host.ID, "--scope", scope) + cmd.Stdin = os.Stdin + cmd.Stdout = opts.IO.Out + cmd.Stderr = opts.IO.ErrOut + if err := cmd.Run(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to install %s from %s: %s\n", + cs.Red("!"), displayName, s.Repo, err) + } + } + + return nil +} + +// relevanceScore computes a numeric ranking score for a search result. +// Higher scores rank first. Signals (in priority order): +// - Exact skill name match (3 000 points) +// - Partial skill name match (1 000 points) +// - Namespace match (500 points) +// - Description contains query (100 points) +// - Repository stars (sqrt bonus, ~2 400 for 6k stars) +func relevanceScore(s skillResult, query string) int { + term := strings.ToLower(query) + termHyphen := strings.ReplaceAll(term, " ", "-") + score := 0 + + // Name match. Normalize spaces to hyphens since skill directory names + // use hyphens as word separators (e.g. query "mcp apps" > "mcp-apps"). + skillLower := strings.ToLower(s.SkillName) + if skillLower == term || skillLower == termHyphen { + score += 3_000 + } else if strings.Contains(skillLower, term) || strings.Contains(skillLower, termHyphen) { + score += 1_000 + } + + // Namespace match. + if s.Namespace != "" && strings.Contains(strings.ToLower(s.Namespace), term) { + score += 500 + } + + // Description match. + if strings.Contains(strings.ToLower(s.Description), term) { + score += 100 + } + + // Stars bonus: use √n scaling so popular repos rank meaningfully higher + // without completely drowning out less-popular but more relevant results. + if s.Stars > 0 { + score += int(math.Sqrt(float64(s.Stars)) * 30) + } + + return score +} + +// filterByRelevance removes results that are not meaningfully related to +// the query. A result is kept if the query term appears in the skill name, +// the namespace, the YAML description, or the repository owner or name. +func filterByRelevance(skills []skillResult, query string) []skillResult { + queryTerm := strings.ToLower(query) + termHyphen := strings.ReplaceAll(queryTerm, " ", "-") + + filtered := skills[:0] // reuse backing array + for _, s := range skills { + nameLower := strings.ToLower(s.SkillName) + namespaceLower := strings.ToLower(s.Namespace) + descLower := strings.ToLower(s.Description) + ownerLower := strings.ToLower(s.Owner) + repoLower := strings.ToLower(s.RepoName) + + if strings.Contains(nameLower, queryTerm) || + strings.Contains(nameLower, termHyphen) || + strings.Contains(namespaceLower, queryTerm) || + strings.Contains(descLower, queryTerm) || + strings.Contains(ownerLower, queryTerm) || + strings.Contains(repoLower, queryTerm) { + filtered = append(filtered, s) + } + } + return filtered +} + +// rankByRelevance sorts results by multi-signal score, highest first. +func rankByRelevance(skills []skillResult, query string) { + sort.SliceStable(skills, func(i, j int) bool { + return relevanceScore(skills[i], query) > relevanceScore(skills[j], query) + }) +} + +// couldBeOwner returns true if s looks like a valid GitHub username/org. +// GitHub usernames: 1-39 chars, alphanumeric or hyphen, no leading/trailing hyphens. +func couldBeOwner(s string) bool { + if len(s) == 0 || len(s) > 39 { + return false + } + for i, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + continue + case c == '-': + if i == 0 || i == len(s)-1 { + return false + } + default: + return false + } + } + return true +} + +// isRateLimitError checks whether err is a GitHub API rate-limit response. +// Per GitHub docs, a rate limit is indicated by: +// - HTTP 429 (always a rate limit) +// - HTTP 403 with x-ratelimit-remaining: 0 (primary rate limit) +// - HTTP 403 with a retry-after header (secondary rate limit) +func isRateLimitError(err error) bool { + var httpErr api.HTTPError + if !errors.As(err, &httpErr) { + return false + } + if httpErr.StatusCode == 429 { + return true + } + if httpErr.StatusCode == 403 { + if httpErr.Headers.Get("x-ratelimit-remaining") == "0" { + return true + } + if httpErr.Headers.Get("retry-after") != "" { + return true + } + } + return false +} + +// rateLimitErrorMessage returns a user-friendly message for rate-limit errors. +const rateLimitErrorMessage = "GitHub API rate limit exceeded. Please wait a minute and try again." + +// executeSearch performs a single GitHub Code Search API call. +func executeSearch(client *api.Client, host, query string, page, pageSize int) (*codeSearchResult, error) { + apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d&page=%d", + url.QueryEscape(query), pageSize, page) + var result codeSearchResult + err := client.REST(host, "GET", apiPath, nil, &result) + if err != nil && isRateLimitError(err) { + return nil, fmt.Errorf("%s", rateLimitErrorMessage) + } + return &result, err +} + +// fetchPrimaryPages fetches enough API pages from GitHub Code Search to +// cover the requested display page, accounting for filtering losses. +func fetchPrimaryPages(client *api.Client, host, query string, displayPage, displayLimit int) ([]codeSearchItem, int, error) { + // Over-fetch to account for deduplication + filtering losses. + // The Code Search API is rate-limited at 10 req/min, so we keep + // page fetching conservative. Two pages (200 results) provides a + // good buffer for typical filter rates while staying well within + // the rate-limit budget. + needed := displayPage * displayLimit * 3 + numPages := (needed + searchPageSize - 1) / searchPageSize + if numPages < 1 { + numPages = 1 + } + maxAPIPages := maxResults / searchPageSize + if numPages > maxAPIPages { + numPages = maxAPIPages + } + + var allItems []codeSearchItem + var totalCount int + for p := 1; p <= numPages; p++ { + result, err := executeSearch(client, host, query, p, searchPageSize) + if err != nil { + if p == 1 { + return nil, 0, err + } + break // partial results from earlier pages are OK + } + allItems = append(allItems, result.Items...) + totalCount = result.TotalCount + if len(result.Items) < searchPageSize { + break // no more results available + } + } + return allItems, totalCount, nil +} + +// deduplicateResults extracts unique (repo, namespace, skill name) triples from code search hits. +func deduplicateResults(items []codeSearchItem) []skillResult { + // skillResultKey is a typed map key that deduplicates by (repo, namespace, + // skill name). All fields are lowercased for case-insensitive comparison. + type skillResultKey struct { + repo string + namespace string + skillName string + } + seen := make(map[skillResultKey]struct{}) + var results []skillResult + + for _, item := range items { + skillName, namespace := extractSkillInfo(item.Path) + if skillName == "" { + continue + } + key := skillResultKey{ + repo: strings.ToLower(item.Repository.FullName), + namespace: strings.ToLower(namespace), + skillName: strings.ToLower(skillName), + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + owner, repoName := splitRepo(item.Repository.FullName) + results = append(results, skillResult{ + Repo: item.Repository.FullName, + Owner: owner, + RepoName: repoName, + SkillName: skillName, + Namespace: namespace, + Path: item.Path, + BlobSHA: item.SHA, + }) + } + + return results +} + +// splitRepo splits "owner/repo" into its components. +func splitRepo(fullName string) (string, string) { + parts := strings.SplitN(fullName, "/", 2) + if len(parts) != 2 { + return fullName, "" + } + return parts[0], parts[1] +} + +// fetchDescriptions fetches SKILL.md frontmatter descriptions concurrently +// for all search results. Each result may come from a different repo. +func fetchDescriptions(client *api.Client, host string, skills []skillResult) map[int]string { + const maxWorkers = 10 + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + + descs := make(map[int]string) + + for i := range skills { + if skills[i].BlobSHA == "" { + continue + } + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + content, err := discovery.FetchBlob(client, host, skills[idx].Owner, skills[idx].RepoName, skills[idx].BlobSHA) + if err != nil { + return + } + result, err := frontmatter.Parse(content) + if err != nil { + return + } + + mu.Lock() + descs[idx] = result.Metadata.Description + mu.Unlock() + }(i) + } + wg.Wait() + + return descs +} + +// extractSkillInfo derives the skill name and namespace from a SKILL.md path, +// but only if the path matches a known skill convention. Returns empty strings +// for non-conforming paths. +func extractSkillInfo(filePath string) (name, namespace string) { + return discovery.MatchSkillPath(filePath) +} + +// formatStars formats a star count for display (e.g. 1700 > "1.7k"). +// TODO kw: Could be swapped for go-humanize. +func formatStars(n int) string { + if n >= 1000 { + return fmt.Sprintf("%.1fk", float64(n)/1000) + } + return fmt.Sprintf("%d", n) +} + +// repoInfo holds the subset of repository metadata we fetch for ranking. +type repoInfo struct { + StargazersCount int `json:"stargazers_count"` +} + +// fetchRepoStars fetches stargazer counts for each unique repository in +// the result set, using bounded concurrency. +func fetchRepoStars(client *api.Client, host string, skills []skillResult) map[int]int { + const maxWorkers = 10 + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + + repoStars := make(map[string]int) + seen := make(map[string]bool) + + for _, s := range skills { + if seen[s.Repo] { + continue + } + seen[s.Repo] = true + + wg.Add(1) + go func(owner, repo, fullName string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var info repoInfo + if err := client.REST(host, "GET", apiPath, nil, &info); err != nil { + return + } + mu.Lock() + repoStars[fullName] = info.StargazersCount + mu.Unlock() + }(s.Owner, s.RepoName, s.Repo) + } + wg.Wait() + + result := make(map[int]int, len(skills)) + for i, s := range skills { + if stars, ok := repoStars[s.Repo]; ok { + result[i] = stars + } + } + return result +} diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go new file mode 100644 index 00000000000..cf66ba4acb4 --- /dev/null +++ b/pkg/cmd/skills/search/search_test.go @@ -0,0 +1,653 @@ +package search + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSearchRun_UnsupportedHost(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cfg := config.NewBlankConfig() + authCfg := cfg.Authentication() + authCfg.SetDefaultHost("acme.ghes.com", "user") + cfg.AuthenticationFunc = func() gh.AuthConfig { + return authCfg + } + err := searchRun(&SearchOptions{ + IO: ios, + Query: "terraform", + Page: 1, + Limit: defaultLimit, + HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + Config: func() (gh.Config, error) { return cfg, nil }, + }) + require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") +} + +func TestNewCmdSearch(t *testing.T) { + tests := []struct { + name string + args string + wantOpts SearchOptions + wantErr string + }{ + { + name: "query argument", + args: "terraform", + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + }, + { + name: "with page flag", + args: "terraform --page 3", + wantOpts: SearchOptions{Query: "terraform", Page: 3, Limit: defaultLimit}, + }, + { + name: "with limit flag", + args: "terraform --limit 5", + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: 5}, + }, + { + name: "with limit short flag", + args: "terraform -L 10", + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: 10}, + }, + { + name: "with owner flag", + args: "terraform --owner hashicorp", + wantOpts: SearchOptions{Query: "terraform", Owner: "hashicorp", Page: 1, Limit: defaultLimit}, + }, + { + name: "no arguments", + args: "", + wantErr: "cannot search: query argument required", + }, + { + name: "invalid page", + args: "terraform --page 0", + wantErr: "invalid page number: 0", + }, + { + name: "query too short", + args: "a", + wantErr: "search query must be at least 2 characters", + }, + { + name: "query too short single char", + args: "x", + wantErr: "search query must be at least 2 characters", + }, + { + name: "invalid limit zero", + args: "terraform --limit 0", + wantErr: "invalid limit: 0", + }, + { + name: "invalid limit negative", + args: "terraform --limit -1", + wantErr: "invalid limit: -1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var gotOpts *SearchOptions + cmd := NewCmdSearch(f, &telemetry.NoOpService{}, func(opts *SearchOptions) error { + gotOpts = opts + return nil + }) + + argv := []string{} + if tt.args != "" { + argv = strings.Fields(tt.args) + } + cmd.SetArgs(argv) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.Query, gotOpts.Query) + assert.Equal(t, tt.wantOpts.Owner, gotOpts.Owner) + assert.Equal(t, tt.wantOpts.Page, gotOpts.Page) + assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + }) + } +} + +func TestSearchRun(t *testing.T) { + const emptyCodeResponse = `{"total_count": 0, "incomplete_results": false, "items": []}` + + // stubKeywordSearch registers the HTTP stubs needed for a keyword search. + // searchByKeyword fires up to 3 concurrent search/code requests (path, + // owner, primary). Stubs are one-shot in httpmock, so we register one + // per request. + stubKeywordSearch := func(reg *httpmock.Registry, codeResponse string) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(codeResponse), + ) + } + } + + tests := []struct { + name string + opts *SearchOptions + tty bool + httpStubs func(*httpmock.Registry) + wantStdout string + wantStderr string + wantErr string + }{ + { + name: "displays results in non-TTY", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "github/awesome-skills\tterraform\t\t0\n", + }, + { + name: "deduplicates results", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform-aws/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "github/awesome-skills\tterraform\t\t0\ngithub/awesome-skills\tterraform-aws\t\t0\n", + }, + { + name: "no results", + tty: true, + opts: &SearchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, emptyCodeResponse) + }, + wantErr: `no skills found matching "nonexistent"`, + }, + { + name: "nested skill path", + tty: false, + opts: &SearchOptions{Query: "my-skill", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/author/my-skill/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + wantStdout: "org/repo\tauthor/my-skill\t\t0\n", + }, + { + name: "ranks name-matching results first", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform-deploy/SKILL.md", "repository": {"full_name": "org/repo1"}}, + {"name": "SKILL.md", "path": "skills/terraform-plan/SKILL.md", "repository": {"full_name": "org/repo2"}}, + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo3"}} + ]}`) + }, + // exact name match "terraform" first, then partial matches alphabetically by score + wantStdout: "org/repo3\tterraform\t\t0\norg/repo1\tterraform-deploy\t\t0\norg/repo2\tterraform-plan\t\t0\n", + }, + { + name: "caps total pages at 1000-result limit", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 5000, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + // In non-TTY mode, no header or pagination text is shown + wantStdout: "org/repo\tterraform\t\t0\n", + }, + { + name: "page beyond available results", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 999, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + wantErr: `no skills found on page 999 for query "terraform"`, + }, + { + name: "namespaced skills are kept distinct in same repo", + tty: false, + opts: &SearchOptions{Query: "commit", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 2, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/kynan/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}}, + {"name": "SKILL.md", "path": "skills/will/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}} + ]}`) + }, + wantStdout: "org/skills-repo\tkynan/commit\t\t0\norg/skills-repo\twill/commit\t\t0\n", + }, + { + name: "json output with selected fields", + tty: false, + opts: func() *SearchOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"repo", "skillName", "stars"}) + return &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit, Exporter: exporter} + }(), + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "[{\"repo\":\"github/awesome-skills\",\"skillName\":\"terraform\",\"stars\":0}]\n", + }, + { + name: "json output empty results", + tty: false, + opts: func() *SearchOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"repo", "skillName"}) + return &SearchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit, Exporter: exporter} + }(), + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, emptyCodeResponse) + }, + wantStdout: "[]\n", + }, + { + name: "rate limit error returns friendly message", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + // All search/code calls return 403 with x-ratelimit-remaining: 0 + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.WithHeader( + httpmock.StatusJSONResponse(403, map[string]string{"message": "API rate limit exceeded"}), + "x-ratelimit-remaining", "0", + ), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "HTTP 429 returns rate limit error", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StatusStringResponse(429, `{"message": "Too Many Requests"}`), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "HTTP 403 with Retry-After returns rate limit error", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.WithHeader( + httpmock.StatusJSONResponse(403, map[string]string{"message": "secondary rate limit"}), + "Retry-After", "60", + ), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "no results with owner scope", + tty: true, + opts: &SearchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + // With --owner set, only path + primary searches fire (no owner search). + for range 2 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(emptyCodeResponse), + ) + } + }, + wantErr: `no skills found matching "nonexistent" from owner "monalisa"`, + }, + { + name: "enriches results with blob descriptions", + tty: false, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", + "repository": {"full_name": "org/repo"}} + ]}` + stubKeywordSearch(reg, codeResponse) + // Blob fetch for description enrichment + reg.Register( + httpmock.REST("GET", "repos/org/repo/git/blobs/abc123"), + httpmock.JSONResponse(map[string]string{ + "content": "LS0tCmRlc2NyaXB0aW9uOiBBdXRvbWF0ZXMgVGVycmFmb3JtIGluZnJhc3RydWN0dXJlCi0tLQojIFRlcnJhZm9ybSBTa2lsbAo=", + "encoding": "base64", + }), + ) + // Repo stars fetch + reg.Register( + httpmock.REST("GET", "repos/org/repo"), + httpmock.JSONResponse(map[string]int{"stargazers_count": 42}), + ) + }, + wantStdout: "org/repo\tterraform\tAutomates Terraform infrastructure\t42\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + tt.opts.IO = ios + tt.opts.Telemetry = &telemetry.NoOpService{} + + defer reg.Verify(t) + err := searchRun(tt.opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} + +func TestDeduplicateResults(t *testing.T) { + items := []codeSearchItem{ + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/docker/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "other/repo"}}, + } + + results := deduplicateResults(items) + + assert.Equal(t, 3, len(results)) + assert.Equal(t, "org/repo", results[0].Repo) + assert.Equal(t, "org", results[0].Owner) + assert.Equal(t, "repo", results[0].RepoName) + assert.Equal(t, "terraform", results[0].SkillName) + assert.Equal(t, "docker", results[1].SkillName) + assert.Equal(t, "other/repo", results[2].Repo) + assert.Equal(t, "other", results[2].Owner) + assert.Equal(t, "terraform", results[2].SkillName) +} + +func TestDeduplicateResults_Namespaced(t *testing.T) { + items := []codeSearchItem{ + {Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/will/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // duplicate + {Path: "skills/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // non-namespaced + } + + results := deduplicateResults(items) + + require.Equal(t, 3, len(results)) + assert.Equal(t, "commit", results[0].SkillName) + assert.Equal(t, "kynan", results[0].Namespace) + assert.Equal(t, "commit", results[1].SkillName) + assert.Equal(t, "will", results[1].Namespace) + assert.Equal(t, "commit", results[2].SkillName) + assert.Equal(t, "", results[2].Namespace) +} + +func TestExtractSkillInfo(t *testing.T) { + tests := []struct { + path string + wantName string + wantNamespace string + }{ + {"skills/terraform/SKILL.md", "terraform", ""}, + {"skills/author/my-skill/SKILL.md", "my-skill", "author"}, + {"SKILL.md", "", ""}, + {"skills/docker/SKILL.md", "docker", ""}, + // Root-level convention + {"my-skill/SKILL.md", "my-skill", ""}, + // Plugins convention + {"plugins/openai/skills/chat/SKILL.md", "chat", "openai"}, + // Non-matching paths should be filtered out + {"random/nested/deep/SKILL.md", "", ""}, + {".hidden/SKILL.md", "", ""}, + // Same-name skills with different namespaces + {"skills/kynan/commit/SKILL.md", "commit", "kynan"}, + {"skills/will/commit/SKILL.md", "commit", "will"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + gotName, gotNamespace := extractSkillInfo(tt.path) + assert.Equal(t, tt.wantName, gotName) + assert.Equal(t, tt.wantNamespace, gotNamespace) + }) + } +} + +func TestFilterByRelevance(t *testing.T) { + skills := []skillResult{ + {Repo: "org/repo1", Owner: "org", RepoName: "repo1", SkillName: "terraform"}, + {Repo: "org/repo2", Owner: "org", RepoName: "repo2", SkillName: "docker"}, + {Repo: "terraform-corp/tools", Owner: "terraform-corp", RepoName: "tools", SkillName: "linter"}, + {Repo: "acme/terraform-tools", Owner: "acme", RepoName: "terraform-tools", SkillName: "validator"}, + {Repo: "x/y", Owner: "x", RepoName: "y", SkillName: "unrelated", Description: "terraform integration"}, + {Repo: "x/z", Owner: "x", RepoName: "z", SkillName: "noise"}, + {Repo: "org/repo3", Owner: "org", RepoName: "repo3", SkillName: "deploy", Namespace: "terraform"}, + } + + filtered := filterByRelevance(skills, "terraform") + + // Should keep: name match (terraform), owner match (terraform-corp), + // repo name match (terraform-tools), description match (terraform integration), + // namespace match (terraform/deploy). + // Should drop: docker, noise. + assert.Equal(t, 5, len(filtered)) + assert.Equal(t, "terraform", filtered[0].SkillName) + assert.Equal(t, "linter", filtered[1].SkillName) + assert.Equal(t, "validator", filtered[2].SkillName) + assert.Equal(t, "unrelated", filtered[3].SkillName) + assert.Equal(t, "deploy", filtered[4].SkillName) + assert.Equal(t, "terraform", filtered[4].Namespace) +} + +func TestRankByRelevance(t *testing.T) { + skills := []skillResult{ + {Repo: "org/repo1", Owner: "org", SkillName: "devops"}, + {Repo: "org/repo2", Owner: "org", SkillName: "terraform-plan"}, + {Repo: "org/repo3", Owner: "org", SkillName: "docker", Description: "Manages terraform docker containers"}, + {Repo: "org/repo4", Owner: "org", SkillName: "terraform"}, + } + + rankByRelevance(skills, "terraform") + + // Exact name match scores highest (3 000), then partial name (1 000), + // then description match (100), then body-only (0). + assert.Equal(t, "terraform", skills[0].SkillName) + assert.Equal(t, "terraform-plan", skills[1].SkillName) + assert.Equal(t, "docker", skills[2].SkillName) + assert.Equal(t, "devops", skills[3].SkillName) +} + +func TestRankByRelevanceStarsTiebreak(t *testing.T) { + skills := []skillResult{ + {Repo: "small/repo", Owner: "small", SkillName: "terraform", Stars: 10}, + {Repo: "big/repo", Owner: "big", SkillName: "terraform", Stars: 5000}, + } + + rankByRelevance(skills, "terraform") + + // Both have exact name match; big/repo wins on stars tiebreak + assert.Equal(t, "big/repo", skills[0].Repo) + assert.Equal(t, "small/repo", skills[1].Repo) +} + +func TestFormatStars(t *testing.T) { + assert.Equal(t, "0", formatStars(0)) + assert.Equal(t, "42", formatStars(42)) + assert.Equal(t, "999", formatStars(999)) + assert.Equal(t, "1.0k", formatStars(1000)) + assert.Equal(t, "1.7k", formatStars(1700)) + assert.Equal(t, "12.5k", formatStars(12500)) +} + +func TestQualifiedName(t *testing.T) { + tests := []struct { + name string + skill skillResult + want string + }{ + { + name: "no namespace", + skill: skillResult{SkillName: "terraform"}, + want: "terraform", + }, + { + name: "with namespace", + skill: skillResult{SkillName: "commit", Namespace: "kynan"}, + want: "kynan/commit", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.skill.qualifiedName()) + }) + } +} + +func TestDeduplicateByName_Namespaced(t *testing.T) { + // Skills with the same base name but different namespaces should + // be treated as distinct and not collapsed against each other. + skills := []skillResult{ + {Repo: "org/repo1", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo2", SkillName: "commit", Namespace: "will"}, + {Repo: "org/repo3", SkillName: "commit"}, + {Repo: "org/repo4", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo5", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo6", SkillName: "commit", Namespace: "kynan"}, // should be capped (4th kynan/commit) + } + + result := deduplicateByName(skills) + + // kynan/commit capped at 3, will/commit has 1, bare commit has 1 = 5 total + require.Equal(t, 5, len(result)) + assert.Equal(t, "kynan", result[0].Namespace) + assert.Equal(t, "will", result[1].Namespace) + assert.Equal(t, "", result[2].Namespace) + assert.Equal(t, "kynan", result[3].Namespace) + assert.Equal(t, "kynan", result[4].Namespace) + // repo6 should have been dropped + for _, s := range result { + assert.NotEqual(t, "org/repo6", s.Repo) + } +} + +// TestSearchRun_TelemetryRecordsInstallFromResults verifies that when a +// user searches, picks one or more results interactively, and proceeds to +// install them, the search command records a telemetry event capturing +// that the search led to an install attempt. This is the key signal for +// measuring the value of search results: of the searches that ran, how +// many converted to an install? +func TestSearchRun_TelemetryRecordsInstallFromResults(t *testing.T) { + codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", + "repository": {"full_name": "org/repo"}} + ]}` + + reg := &httpmock.Registry{} + defer reg.Verify(t) + // Keyword search fires path + owner + primary (3 requests). + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(codeResponse), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + // Select the single result. + return []int{0}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + // First Select: target agent (0). Second Select: scope (0). + return 0, nil + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + err := searchRun(&SearchOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + Prompter: pm, + Telemetry: recorder, + ExecutablePath: "/nonexistent/gh", // install subprocess will fail; failures are logged, not fatal. + Query: "terraform", + Page: 1, + Limit: defaultLimit, + }) + require.NoError(t, err) + + // The search command no longer records a separate skill_search event; + // only the follow-up skill_search_install event fires when the user + // proceeds to install from the results. + require.Len(t, recorder.Events, 1) + + installEvent := recorder.Events[0] + assert.Equal(t, "skill_search_install", installEvent.Type, + "an install triggered from search results should be recorded as a distinct event") + assert.Equal(t, int64(1), installEvent.Measures["install_count"], + "install_count captures how many results the user chose to install") + // The skill_search_install event must not carry the query or owner: + // these were intentionally removed so that installs from search are + // not linked back to the search terms at the telemetry layer. + assert.Empty(t, installEvent.Dimensions["query"], + "skill_search_install must not record the search query") + assert.Empty(t, installEvent.Dimensions["owner"], + "skill_search_install must not record the search owner filter") +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go new file mode 100644 index 00000000000..1399d049b73 --- /dev/null +++ b/pkg/cmd/skills/skills.go @@ -0,0 +1,62 @@ +package skills + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/pkg/cmd/skills/install" + skilllist "github.com/cli/cli/v2/pkg/cmd/skills/list" + "github.com/cli/cli/v2/pkg/cmd/skills/preview" + "github.com/cli/cli/v2/pkg/cmd/skills/publish" + "github.com/cli/cli/v2/pkg/cmd/skills/search" + "github.com/cli/cli/v2/pkg/cmd/skills/update" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdSkills returns the top-level "skill" command. +func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *cobra.Command { + cmd := &cobra.Command{ + Use: "skill ", + Short: "Install and manage agent skills (preview)", + Long: heredoc.Doc(` + Install and manage agent skills from GitHub repositories. + + Working with agent skills in the GitHub CLI is in preview and + subject to change without notice. + `), + Aliases: []string{"skills"}, + GroupID: "core", + Example: heredoc.Doc(` + # Search for skills + $ gh skill search terraform + + # Install a skill + $ gh skill install github/awesome-copilot documentation-writer + + # List installed skills + $ gh skill list + + # Preview a skill before installing + $ gh skill preview github/awesome-copilot documentation-writer + + # Update all installed skills + $ gh skill update --all + + # Validate skills for publishing + $ gh skill publish --dry-run + `), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + telemetry.SetSampleRate(ghtelemetry.SAMPLE_ALL) + return nil + }, + } + + cmd.AddCommand(install.NewCmdInstall(f, telemetry, nil)) + cmd.AddCommand(skilllist.NewCmdList(f, telemetry, nil)) + cmd.AddCommand(preview.NewCmdPreview(f, telemetry, nil)) + cmd.AddCommand(publish.NewCmdPublish(f, nil)) + cmd.AddCommand(search.NewCmdSearch(f, telemetry, nil)) + cmd.AddCommand(update.NewCmdUpdate(f, nil)) + + return cmd +} diff --git a/pkg/cmd/skills/skills_test.go b/pkg/cmd/skills/skills_test.go new file mode 100644 index 00000000000..eb8bb465c0e --- /dev/null +++ b/pkg/cmd/skills/skills_test.go @@ -0,0 +1,19 @@ +package skills_test + +import ( + "testing" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmd/skills" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/stretchr/testify/require" +) + +func TestSkillCommandsAreSampledAt100(t *testing.T) { + spy := &telemetry.CommandRecorderSpy{} + factory := &cmdutil.Factory{} + cmd := skills.NewCmdSkills(factory, spy) + cmd.PersistentPreRunE(nil, []string{}) + require.Equal(t, ghtelemetry.SAMPLE_ALL, spy.LastSampleRate) +} diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go new file mode 100644 index 00000000000..88e3518554f --- /dev/null +++ b/pkg/cmd/skills/update/update.go @@ -0,0 +1,587 @@ +package update + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// UpdateOptions holds all dependencies and user-provided flags for the update command. +type UpdateOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + GitClient *git.Client + + Skills []string + All bool + Force bool + DryRun bool + Unpin bool + Dir string +} + +// installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter. +type installedSkill struct { + name string + repoHost string + owner string + repo string + treeSHA string // tree SHA at install time + pinned string // explicit pin value (empty = unpinned) + sourcePath string // original path in source repo (e.g. "skills/author/name") + dir string // local directory path + host *registry.AgentHost + scope registry.Scope + metadataErr error +} + +// pendingUpdate describes a single skill that has an available update. +type pendingUpdate struct { + local installedSkill + newSHA string // new tree SHA from remote + resolved *discovery.ResolvedRef + skill discovery.Skill +} + +// NewCmdUpdate creates the "skills update" command. +func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Command { + opts := &UpdateOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + Config: f.Config, + GitClient: f.GitClient, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "update [...] [flags]", + Short: "Update installed skills to their latest versions (preview)", + Long: heredoc.Docf(` + Checks installed skills for available updates by comparing the local + tree SHA (from %[1]sSKILL.md%[1]s frontmatter) against the remote repository. + + Scans all known agent host directories (Copilot, Claude, Cursor, Codex, + Gemini, Antigravity) in both project and user scope automatically. + + Without arguments, checks all installed skills. With skill names, + checks only those specific skills. + + Pinned skills (installed with %[1]s--pin%[1]s) are skipped with a notice. + Use %[1]s--unpin%[1]s to clear the pinned version and include those skills + in the update. + + Skills without GitHub metadata (e.g. installed manually or by another + tool) are prompted for their source repository in interactive mode. + With %[1]s--all%[1]s or in non-interactive mode, they are skipped with a notice. + The update re-downloads the skill with metadata injected, so future + updates work automatically. + + With %[1]s--force%[1]s, re-downloads skills even when the remote version matches + the local tree SHA. This overwrites locally modified skill files with + their original content, but does not remove extra files added locally. + + In interactive mode, shows which skills have updates and asks for + confirmation before proceeding. With %[1]s--all%[1]s, updates without prompting. + With %[1]s--dry-run%[1]s, reports available updates without modifying any files. + `, "`"), + Example: heredoc.Doc(` + # Check and update all skills interactively + $ gh skill update + + # Update specific skills + $ gh skill update mcp-cli git-commit + + # Update all without prompting + $ gh skill update --all + + # Re-download all skills (restore locally modified files) + $ gh skill update --force --all + + # Check for updates without applying (read-only) + $ gh skill update --dry-run + + # Unpin skills and update them to latest + $ gh skill update --unpin + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Skills = args + if runF != nil { + return runF(opts) + } + return updateRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.All, "all", false, "Update all skills without prompting") + cmd.Flags().BoolVar(&opts.Force, "force", false, "Re-download even if already up to date") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Report available updates without modifying files") + cmd.Flags().BoolVar(&opts.Unpin, "unpin", false, "Clear pinned version and include pinned skills in update") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") + + return cmd +} + +func updateRun(opts *UpdateOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() + + // Scan for installed skills + var installed []installedSkill + if opts.Dir != "" { + skills, scanErr := scanInstalledSkills(opts.Dir, nil, "") + if scanErr != nil { + return fmt.Errorf("could not scan directory: %w", scanErr) + } + installed = skills + } else { + installed = scanAllAgents(gitRoot, homeDir) + } + + if len(installed) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No installed skills found.\n") + return nil + } + + // Filter to requested skills if specified + if len(opts.Skills) > 0 { + requested := make(map[string]bool, len(opts.Skills)) + for _, name := range opts.Skills { + requested[name] = true + } + var filtered []installedSkill + for _, s := range installed { + if requested[s.name] { + filtered = append(filtered, s) + } + } + if len(filtered) == 0 { + return fmt.Errorf("none of the specified skills are installed") + } + installed = filtered + } + + // Skip skills with invalid metadata rather than aborting the entire + // update run. One corrupt skill should not prevent updating others. + { + var valid []installedSkill + for _, s := range installed { + if s.metadataErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: invalid repository metadata: %s\n", cs.WarningIcon(), s.name, s.metadataErr) + continue + } + valid = append(valid, s) + } + installed = valid + } + + if len(installed) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No updatable skills found.\n") + return nil + } + + // Prompt for metadata on skills missing it (before starting progress indicator) + var noMeta []string + // Track skills where the user provided a source repo interactively. + // Keyed by directory path to avoid collisions when the same skill name + // is installed across multiple hosts or scopes. + type promptedEntry struct { + name string + source string // "owner/repo" + } + prompted := make(map[string]promptedEntry) // dir > entry + for i := range installed { + s := &installed[i] + if s.owner != "" && s.repo != "" { + continue + } + if !canPrompt || opts.All { + noMeta = append(noMeta, s.name) + continue + } + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata\n", cs.WarningIcon(), s.name) + owner, repo, reason, ok, promptErr := promptForSkillOrigin(opts.Prompter, s.name) + if promptErr != nil { + return promptErr + } + if !ok { + if reason != "" { + fmt.Fprintf(opts.IO.ErrOut, " %s %s\n", cs.WarningIcon(), reason) + } + fmt.Fprintf(opts.IO.ErrOut, " Skipping %s\n", s.name) + continue + } + s.owner = owner + s.repo = repo + s.repoHost = source.SupportedHost + prompted[s.dir] = promptedEntry{name: s.name, source: owner + "/" + repo} + } + + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + + var updates []pendingUpdate + var pinned []installedSkill + + type repoKey struct{ host, owner, repo string } + repoSkills := make(map[repoKey][]discovery.Skill) + repoRefs := make(map[repoKey]*discovery.ResolvedRef) + repoErrors := make(map[repoKey]bool) + + for _, s := range installed { + if s.owner == "" || s.repo == "" { + continue + } + if s.pinned != "" && !opts.Unpin { + pinned = append(pinned, s) + continue + } + + key := repoKey{s.repoHost, s.owner, s.repo} + + if repoErrors[key] { + continue + } + + // Resolve ref and discover skills once per repo + if _, ok := repoRefs[key]; !ok { + resolved, resolveErr := discovery.ResolveRef(apiClient, s.repoHost, s.owner, s.repo, "") + if resolveErr != nil { + repoErrors[key] = true + opts.IO.StopProgressIndicator() + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: could not resolve %s/%s: %v\n", cs.WarningIcon(), s.name, s.owner, s.repo, resolveErr) + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + continue + } + repoRefs[key] = resolved + + skills, discoverErr := discovery.DiscoverSkills(apiClient, s.repoHost, s.owner, s.repo, resolved.SHA) + if discoverErr != nil { + repoErrors[key] = true + opts.IO.StopProgressIndicator() + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: %v\n", cs.WarningIcon(), s.name, discoverErr) + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + continue + } + repoSkills[key] = skills + } + + resolved := repoRefs[key] + for _, remote := range repoSkills[key] { + matched := false + if s.sourcePath != "" { + matched = remote.Path == s.sourcePath + } else { + matched = remote.InstallName() == s.name + } + if matched && (remote.TreeSHA != s.treeSHA || opts.Force) { + updates = append(updates, pendingUpdate{ + local: s, + newSHA: remote.TreeSHA, + resolved: resolved, + skill: remote, + }) + break + } + } + } + + opts.IO.StopProgressIndicator() + + // Warn about prompted skills that weren't found in the remote repo + for _, entry := range prompted { + parts := strings.SplitN(entry.source, "/", 2) + key := repoKey{source.SupportedHost, parts[0], parts[1]} + skills, resolved := repoSkills[key] + if !resolved { + continue + } + found := false + for _, remote := range skills { + if remote.InstallName() == entry.name || remote.Name == entry.name { + found = true + break + } + } + if !found { + fmt.Fprintf(opts.IO.ErrOut, "%s Skill %s not found in %s\n", cs.WarningIcon(), entry.name, entry.source) + } + } + + for _, s := range pinned { + fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Muted("⊘"), s.name, s.pinned) + } + for _, name := range noMeta { + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata. Run `gh skill update %s` interactively to add metadata, or reinstall to enable updates\n", cs.WarningIcon(), name, name) + } + + if len(updates) == 0 { + if opts.Force && opts.DryRun { + fmt.Fprintf(opts.IO.ErrOut, "All skills are up to date. Use --force without --dry-run to re-download anyway.\n") + } else { + fmt.Fprintf(opts.IO.ErrOut, "All skills are up to date.\n") + } + return nil + } + + fmt.Fprintf(opts.IO.ErrOut, "\n%d update(s) available:\n", len(updates)) + for _, u := range updates { + if u.local.treeSHA == u.newSHA { + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s (reinstall) [%s]\n", + cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, + git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref)) + } else { + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s > %s [%s]\n", + cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, + cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), + discovery.ShortRef(u.resolved.Ref)) + } + } + fmt.Fprintln(opts.IO.ErrOut) + + if opts.DryRun { + return nil + } + + if !opts.All { + if !canPrompt { + return fmt.Errorf("updates available; re-run with --all to apply, or run interactively to confirm") + } + confirmed, confirmErr := opts.Prompter.Confirm(fmt.Sprintf("Update %d skill(s)?", len(updates)), true) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + fmt.Fprintf(opts.IO.ErrOut, "Update cancelled.\n") + return cmdutil.CancelError + } + } + + var failed bool + for _, u := range updates { + installOpts := &installer.Options{ + Host: u.local.repoHost, + Owner: u.local.owner, + Repo: u.local.repo, + Ref: u.resolved.Ref, + SHA: u.resolved.SHA, + Skills: []discovery.Skill{u.skill}, + AgentHost: u.local.host, + Scope: u.local.scope, + GitRoot: gitRoot, + HomeDir: homeDir, + Client: apiClient, + } + // When updating skills from a custom --dir, host is nil. + // Use the skill's install root as the target. For namespaced + // skills (name contains "/"), the dir is two levels below the + // root instead of one. + if u.local.host == nil { + base := filepath.Dir(u.local.dir) + if strings.Contains(u.local.name, "/") { + base = filepath.Dir(base) + } + installOpts.Dir = base + } + _, installErr := installer.Install(installOpts) + if installErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, installErr) + failed = true + continue + } + + // When the install location has changed (e.g. migrating from a + // namespaced layout to flat), remove the old directory so that the + // stale copy does not shadow the freshly installed one. + newDir := filepath.Join(installOpts.Dir, u.skill.Name) + if installOpts.Dir == "" && u.local.host != nil { + if d, err := u.local.host.InstallDir(u.local.scope, gitRoot, homeDir); err == nil { + newDir = filepath.Join(d, u.skill.Name) + } + } + if newDir != "" && u.local.dir != "" && filepath.Clean(newDir) != filepath.Clean(u.local.dir) { + _ = os.RemoveAll(u.local.dir) + // Remove the parent if it is now empty (leftover namespace directory). + parent := filepath.Dir(u.local.dir) + if entries, readErr := os.ReadDir(parent); readErr == nil && len(entries) == 0 { + _ = os.Remove(parent) + } + } + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name) + } else { + fmt.Fprintf(opts.IO.Out, "Updated %s\n", u.local.name) + } + } + + if failed { + return cmdutil.SilentError + } + + return nil +} + +// scanAllAgents walks every registered agent's skill directory (project + user scope) and +// collects installed skills. Shared install roots are scanned only once. +func scanAllAgents(gitRoot, homeDir string) []installedSkill { + scannedDirs := make(map[string]bool) + var all []installedSkill + + for i := range registry.Agents { + host := ®istry.Agents[i] + for _, scope := range []registry.Scope{registry.ScopeProject, registry.ScopeUser} { + dir, err := host.InstallDir(scope, gitRoot, homeDir) + if err != nil { + continue + } + if scannedDirs[dir] { + continue + } + scannedDirs[dir] = true + skills, err := scanInstalledSkills(dir, host, scope) + if err != nil { + continue + } + all = append(all, skills...) + } + } + + return all +} + +// scanInstalledSkills reads all SKILL.md files in a skills directory and +// extracts GitHub metadata from their frontmatter. It handles both flat +// layouts ({dir}/{name}/SKILL.md) and namespaced layouts +// ({dir}/{namespace}/{name}/SKILL.md). +func scanInstalledSkills(skillsDir string, host *registry.AgentHost, scope registry.Scope) ([]installedSkill, error) { + entries, err := os.ReadDir(skillsDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("could not read skills directory: %w", err) + } + + var skills []installedSkill + for _, e := range entries { + if !e.IsDir() { + continue + } + + // Flat layout: {dir}/{name}/SKILL.md + skillFile := filepath.Join(skillsDir, e.Name(), "SKILL.md") + if data, readErr := os.ReadFile(skillFile); readErr == nil { + if s, ok := parseInstalledSkill(data, e.Name(), filepath.Join(skillsDir, e.Name()), host, scope); ok { + skills = append(skills, s) + continue + } + } + + // Namespaced layout: {dir}/{namespace}/{name}/SKILL.md + subEntries, subErr := os.ReadDir(filepath.Join(skillsDir, e.Name())) + if subErr != nil { + continue + } + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + subSkillFile := filepath.Join(skillsDir, e.Name(), sub.Name(), "SKILL.md") + if data, readErr := os.ReadFile(subSkillFile); readErr == nil { + installName := e.Name() + "/" + sub.Name() + if s, ok := parseInstalledSkill(data, installName, filepath.Join(skillsDir, e.Name(), sub.Name()), host, scope); ok { + skills = append(skills, s) + } + } + } + } + + return skills, nil +} + +// parseInstalledSkill parses a SKILL.md file and returns an installedSkill. +func parseInstalledSkill(data []byte, name, dir string, host *registry.AgentHost, scope registry.Scope) (installedSkill, bool) { + result, err := frontmatter.Parse(string(data)) + if err != nil { + return installedSkill{ + name: name, + dir: dir, + host: host, + scope: scope, + metadataErr: fmt.Errorf("invalid SKILL.md: %w", err), + }, true + } + + s := installedSkill{ + name: name, + dir: dir, + host: host, + scope: scope, + } + + if result.Metadata.Meta != nil { + repoInfo, ok, repoErr := source.ParseMetadataRepo(result.Metadata.Meta) + if repoErr != nil { + s.metadataErr = repoErr + } else if ok { + if err := source.ValidateSupportedHost(repoInfo.RepoHost()); err != nil { + s.metadataErr = err + } else { + s.repoHost = repoInfo.RepoHost() + s.owner = repoInfo.RepoOwner() + s.repo = repoInfo.RepoName() + } + } + s.treeSHA, _ = result.Metadata.Meta["github-tree-sha"].(string) + s.pinned, _ = result.Metadata.Meta["github-pinned"].(string) + s.sourcePath, _ = result.Metadata.Meta["github-path"].(string) + } + + return s, true +} + +// promptForSkillOrigin asks the user for the source repository of a skill +// that has no GitHub metadata. +func promptForSkillOrigin(p prompter.Prompter, skillName string) (owner, repo, reason string, ok bool, err error) { + input, err := p.Input( + fmt.Sprintf("Repository for %s (owner/repo):", skillName), "") + if err != nil { + return "", "", "", false, err + } + input = strings.TrimSpace(input) + if input == "" { + return "", "", "", false, nil + } + r, err := ghrepo.FromFullName(input) + if err != nil { + //nolint:nilerr // intentionally converting parse error into a user-facing validation message + return "", "", fmt.Sprintf("invalid repository %q: expected owner/repo", input), false, nil + } + return r.RepoOwner(), r.RepoName(), "", true, nil +} diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go new file mode 100644 index 00000000000..9e9ef44973c --- /dev/null +++ b/pkg/cmd/skills/update/update_test.go @@ -0,0 +1,1236 @@ +package update + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/registry" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdUpdate_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { + return nil + }) + + assert.Equal(t, "update [...] [flags]", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) +} + +func TestNewCmdUpdate_Flags(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdUpdate(f, func(_ *UpdateOptions) error { return nil }) + + flags := []string{"all", "force", "dry-run", "dir", "unpin"} + for _, name := range flags { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) + } +} + +func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + + var gotOpts *UpdateOptions + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { + gotOpts = opts + return nil + }) + + args, _ := shlex.Split("mcp-cli git-commit --all --force") + cmd.SetArgs(args) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, []string{"mcp-cli", "git-commit"}, gotOpts.Skills) + assert.True(t, gotOpts.All) + assert.True(t, gotOpts.Force) +} + +func TestScanInstalledSkills(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + verify func(t *testing.T, skills []installedSkill, err error) + }{ + { + name: "happy path with metadata, no metadata, and pinned skills", + setup: func(t *testing.T, dir string) { + t.Helper() + + // Skill with full metadata + skillDir := filepath.Join(dir, "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := heredoc.Doc(` + --- + name: git-commit + description: Git commit helper + metadata: + github-repo: https://github.com/monalisa/awesome-copilot + github-tree-sha: abc123 + github-path: skills/git-commit + --- + Body content + `) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + // Skill without metadata + noMetaDir := filepath.Join(dir, "unknown-skill") + require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: unknown-skill + --- + No metadata here + `)), 0o644)) + + // Pinned skill + pinnedDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: def456 + github-pinned: v1.0.0 + --- + Pinned content + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 3) + + byName := make(map[string]installedSkill) + for _, s := range skills { + byName[s.name] = s + } + + gc := byName["git-commit"] + assert.Equal(t, "monalisa", gc.owner) + assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "github.com", gc.repoHost) + assert.Equal(t, "abc123", gc.treeSHA) + assert.Equal(t, "skills/git-commit", gc.sourcePath) + assert.Empty(t, gc.pinned) + + us := byName["unknown-skill"] + assert.Empty(t, us.owner) + assert.Empty(t, us.repo) + + ps := byName["pinned-skill"] + assert.Equal(t, "github.com", ps.repoHost) + assert.Equal(t, "v1.0.0", ps.pinned) + }, + }, + { + name: "unsupported host metadata returns error", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "enterprise-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: enterprise-skill + metadata: + github-repo: https://acme.ghes.com/monalisa/octocat-skills + github-tree-sha: abc123 + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + require.Len(t, skills, 1) + require.Error(t, skills[0].metadataErr) + assert.Contains(t, skills[0].metadataErr.Error(), "does not currently support GitHub Enterprise Server") + }, + }, + { + name: "non-existent directory returns nil", + // no setup needed; dir does not exist + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Nil(t, skills) + }, + }, + { + name: "corrupted YAML is skipped gracefully", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "corrupt") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + not: valid: yaml: [broken + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + require.Len(t, skills, 1) + assert.Equal(t, "corrupt", skills[0].name) + assert.ErrorContains(t, skills[0].metadataErr, "invalid SKILL.md") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For the non-existent directory case, pass a path that doesn't exist + dir := filepath.Join(t.TempDir(), "skills") + if tt.setup != nil { + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + } + + skills, err := scanInstalledSkills(dir, nil, "") + tt.verify(t, skills, err) + }) + } +} + +func TestPromptForSkillOrigin(t *testing.T) { + tests := []struct { + name string + input string + wantOK bool + wantOwner string + wantRepo string + wantReason string + }{ + { + name: "valid owner/repo", + input: "monalisa/awesome-copilot", + wantOK: true, + wantOwner: "monalisa", + wantRepo: "awesome-copilot", + }, + { + name: "empty input skips", + input: "", + wantOK: false, + }, + { + name: "invalid format returns reason", + input: "just-a-name", + wantOK: false, + wantReason: "invalid repository", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return tt.input, nil + }, + } + + owner, repo, reason, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantOwner, owner) + assert.Equal(t, tt.wantRepo, repo) + if tt.wantReason != "" { + assert.Contains(t, reason, tt.wantReason) + } + }) + } +} + +func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { + repoDir := t.TempDir() + homeDir := t.TempDir() + + sharedSkillDir := filepath.Join(repoDir, ".agents", "skills", "git-commit") + require.NoError(t, os.MkdirAll(sharedSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sharedSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: git-commit + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: abc123 + --- + Body + `)), 0o644)) + + claudeSkillDir := filepath.Join(repoDir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(claudeSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(claudeSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: def456 + --- + Body + `)), 0o644)) + + skills := scanAllAgents(repoDir, homeDir) + require.Len(t, skills, 2) + + byName := make(map[string]installedSkill) + for _, skill := range skills { + byName[skill.name] = skill + } + + assert.Equal(t, registry.ScopeProject, byName["git-commit"].scope) + assert.Equal(t, registry.ScopeProject, byName["code-review"].scope) +} + +func TestUpdateRun(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + stubs func(reg *httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions + verify func(t *testing.T, dir string) + wantErr string + wantStderr string + wantStdout string + }{ + { + name: "scans all agents when no --dir is set", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: currentsha + github-path: skills/code-review + --- + Installed content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit1", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), + httpmock.StringResponse(`{"sha": "commit1", "tree": [{"path": "skills/code-review", "type": "tree", "sha": "currentsha"}, {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "no installed skills", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "No installed skills found.", + }, + { + name: "specific skill not installed", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "octocat-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: octocat-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: abc + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, + } + }, + wantErr: "none of the specified skills are installed", + }, + { + name: "pinned skills are skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "pinned", + }, + { + name: "no metadata skips in non-interactive mode", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "all skips no-metadata skill without prompting", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", fmt.Errorf("unexpected prompt") + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + } + }, + wantStderr: "Run `gh skill update manual-skill` interactively", + }, + { + name: "all up to date", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "monalisa-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: monalisa-skill + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: abc123def456 + github-path: skills/monalisa-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commitsha123"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "commitsha123", "tree": [{"path": "skills/monalisa-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/monalisa-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "dry run reports available updates", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-repo: https://github.com/hubot/octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + } + }, + wantStderr: "1 update(s) available:", + wantStdout: "hubot-skill", + }, + { + name: "non-interactive without --all errors", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-repo: https://github.com/hubot/octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "updates available; re-run with --all to apply, or run interactively to confirm", + }, + { + name: "force update rewrites SKILL.md on disk", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "namespaced skill with --dir resolves install base correctly", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "monalisa", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/monalisa/code-review + --- + Old namespaced content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/monalisa/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/monalisa/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills/monalisa", "type": "tree", "sha": "nstresha"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBOYW1lc3BhY2VkIFNraWxsIFVwZGF0ZWQ="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + // After update, skill should be installed flat (not namespaced). + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") + assert.NotContains(t, string(content), "Old namespaced content") + // Old namespaced directory should be cleaned up. + _, err = os.Stat(filepath.Join(dir, "monalisa", "code-review")) + assert.True(t, os.IsNotExist(err), "old namespaced directory should be removed") + }, + wantStdout: "Updated monalisa/code-review", + }, + { + name: "install failure during update reports error and continues", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Original content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StatusStringResponse(500, "server error")) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "Original content", "file should not be modified on failure") + }, + wantStderr: "Failed to update code-review", + wantErr: "SilentError", + }, + { + name: "interactive confirm applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "interactive confirm cancelled", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return false, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "CancelError", + wantStderr: "Update cancelled", + }, + { + name: "no-metadata skill prompted interactively and skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "no-metadata skill enriched via prompt then updated", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + Old manual content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit123", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit123"), + httpmock.StringResponse(`{"sha": "commit123", "tree": [{"path": "skills/manual-skill/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills/manual-skill", "type": "tree", "sha": "newtree1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newtree1"), + httpmock.StringResponse(`{"sha": "newtree1", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "blob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob1", "encoding": "base64", "content": "%s"}`, + "IyBNYW51YWwgU2tpbGwgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "manual-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old manual content") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") + }, + wantStdout: "Updated manual-skill", + }, + { + name: "unpin clears pin and applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBVbnBpbm5lZCBhbmQgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Pinned content") + assert.NotContains(t, string(content), "github-pinned") + }, + wantStdout: "Updated pinned-skill", + }, + { + name: "pinned skills still skipped without --unpin", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Unpin: false, + } + }, + wantStderr: "pinned", + }, + { + name: "unpin with dry-run reports update without modifying files", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-repo: https://github.com/octocat/hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-pinned: v1.0.0", "dry-run should not modify files") + }, + wantStderr: "1 update(s) available:", + wantStdout: "pinned-skill", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + + dir := t.TempDir() + if tt.setup != nil { + tt.setup(t, dir) + } + + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + opts := tt.opts(ios, dir, reg) + err := updateRun(opts) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) + } +} diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go index 11d4a0271fd..4f68fbe61be 100644 --- a/pkg/cmd/version/version.go +++ b/pkg/cmd/version/version.go @@ -13,8 +13,9 @@ func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command cmd := &cobra.Command{ Use: "version", Hidden: true, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprint(f.IOStreams.Out, cmd.Root().Annotations["versionInfo"]) + return nil }, } diff --git a/pkg/cmd/workflow/shared/shared_test.go b/pkg/cmd/workflow/shared/shared_test.go index cd53b667c3e..cc9017d3643 100644 --- a/pkg/cmd/workflow/shared/shared_test.go +++ b/pkg/cmd/workflow/shared/shared_test.go @@ -406,7 +406,7 @@ func TestGetWorkflows(t *testing.T) { } } -// generateWorkflows returns an slice of workflows with the given count, labeled +// generateWorkflows returns a slice of workflows with the given count, labeled // with the page number of testing pagination. // The page number is used to generate unique Names and IDs for each workflow. func generateWorkflows(t *testing.T, workflowCount int, pageNum int) []Workflow { diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index f746ec8978d..200314038b2 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -2,9 +2,6 @@ package cmdutil import ( "net/http" - "os" - "path/filepath" - "strings" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" @@ -18,7 +15,7 @@ import ( type Factory struct { AppVersion string - ExecutableName string + ExecutablePath string InvokingAgent string Browser browser.Browser @@ -27,79 +24,24 @@ type Factory struct { IOStreams *iostreams.IOStreams Prompter prompter.Prompter - BaseRepo func() (ghrepo.Interface, error) - Branch func() (string, error) + BaseRepo func() (ghrepo.Interface, error) + Branch func() (string, error) + // It would be nice if Config were just loaded once at startup and an error + // were returned, but this would prevent commands like "gh version" from running. + // So for now, we eagerly load the config and don't fail if there is an error, + // and defer the error handling to commands that need it. + // HOWEVER, as an additional point, the root command setup currently DOES call + // this and errors, so we never get to "gh version" anyway. + // We need to revisit that, but I don't want to make it worse. Config func() (gh.Config, error) HttpClient func() (*http.Client, error) // PlainHttpClient is a special HTTP client that does not automatically set // auth and other headers. This is meant to be used in situations where the // client needs to specify the headers itself (e.g. during login). PlainHttpClient func() (*http.Client, error) - Remotes func() (context.Remotes, error) -} - -// Executable is the path to the currently invoked binary -func (f *Factory) Executable() string { - ghPath := os.Getenv("GH_PATH") - if ghPath != "" { - return ghPath - } - if !strings.ContainsRune(f.ExecutableName, os.PathSeparator) { - f.ExecutableName = executable(f.ExecutableName) - } - return f.ExecutableName -} - -// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. -// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in -// PATH, return the absolute location to the program. -// -// The idea is that the result of this function is callable in the future and refers to the same -// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software -// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. -// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of -// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew -// location. -// -// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute -// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git -// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh -// auth login`, running `brew update` will print out authentication errors as git is unable to locate -// Homebrew-installed `gh`. -func executable(fallbackName string) string { - exe, err := os.Executable() - if err != nil { - return fallbackName - } - - base := filepath.Base(exe) - path := os.Getenv("PATH") - for _, dir := range filepath.SplitList(path) { - p, err := filepath.Abs(filepath.Join(dir, base)) - if err != nil { - continue - } - f, err := os.Lstat(p) - if err != nil { - continue - } - - if p == exe { - return p - } else if f.Mode()&os.ModeSymlink != 0 { - realP, err := filepath.EvalSymlinks(p) - if err != nil { - continue - } - realExe, err := filepath.EvalSymlinks(exe) - if err != nil { - continue - } - if realP == realExe { - return p - } - } - } - - return exe + // ExternalHttpClient is an HTTP client for talking to non-GitHub hosts + // It includes debug logging and a User-Agent header but does not attach any + // authentication tokens or GitHub-specific headers. + ExternalHttpClient func() (*http.Client, error) + Remotes func() (context.Remotes, error) } diff --git a/pkg/cmdutil/repo_override.go b/pkg/cmdutil/repo_override.go index 791dd919a21..b037859e737 100644 --- a/pkg/cmdutil/repo_override.go +++ b/pkg/cmdutil/repo_override.go @@ -52,12 +52,12 @@ func EnableRepoOverride(cmd *cobra.Command, f *Factory) { return err } repoOverride, _ := cmd.Flags().GetString("repo") - f.BaseRepo = OverrideBaseRepoFunc(f, repoOverride) + f.BaseRepo = OverrideBaseRepoFunc(f.BaseRepo, repoOverride) return nil } } -func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, error) { +func OverrideBaseRepoFunc(baseRepoFunc func() (ghrepo.Interface, error), override string) func() (ghrepo.Interface, error) { if override == "" { override = os.Getenv("GH_REPO") } @@ -66,5 +66,5 @@ func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, return ghrepo.FromFullName(override) } } - return f.BaseRepo + return baseRepoFunc } diff --git a/pkg/cmdutil/telemetry.go b/pkg/cmdutil/telemetry.go new file mode 100644 index 00000000000..42169beecec --- /dev/null +++ b/pkg/cmdutil/telemetry.go @@ -0,0 +1,66 @@ +package cmdutil + +import ( + "slices" + "strings" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { + if isTelemetryDisabled(cmd) { + return + } + + if cmd.RunE == nil { + return + } + + currentRunE := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runErr := currentRunE(cmd, args) + + var flags []string + cmd.Flags().Visit(func(f *pflag.Flag) { + flags = append(flags, f.Name) + }) + slices.Sort(flags) + + telemetry.Record(ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: map[string]string{ + "command": cmd.CommandPath(), + "flags": strings.Join(flags, ","), + }, + }) + + return runErr + } +} + +func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { + for _, c := range cmd.Commands() { + RecordTelemetry(c, telemetry) + RecordTelemetryForSubcommands(c, telemetry) + } +} + +func DisableTelemetry(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations["telemetry"] = "disabled" +} + +func DisableTelemetryForSubcommands(cmd *cobra.Command) { + for _, c := range cmd.Commands() { + DisableTelemetry(c) + DisableTelemetryForSubcommands(c) + } +} + +func isTelemetryDisabled(cmd *cobra.Command) bool { + return cmd.Annotations["telemetry"] == "disabled" +} diff --git a/pkg/cmdutil/telemetry_test.go b/pkg/cmdutil/telemetry_test.go new file mode 100644 index 00000000000..bfe4c420ca0 --- /dev/null +++ b/pkg/cmdutil/telemetry_test.go @@ -0,0 +1,168 @@ +package cmdutil_test + +import ( + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecordTelemetry(t *testing.T) { + t.Run("records command path and flags", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("web", false, "") + cmd.Flags().String("repo", "", "") + + parent := &cobra.Command{Use: "pr"} + root := &cobra.Command{Use: "gh"} + root.AddCommand(parent) + parent.AddCommand(cmd) + + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.Flags().Set("web", "true")) + require.NoError(t, cmd.Flags().Set("repo", "cli/cli")) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "command_invocation", event.Type) + assert.Equal(t, "gh pr list", event.Dimensions["command"]) + assert.Equal(t, "repo,web", event.Dimensions["flags"]) + }) + + t.Run("is a no-op when original RunE is nil", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{Use: "test"} + + cmdutil.RecordTelemetry(cmd, recorder) + + assert.Nil(t, cmd.RunE, "RunE should remain nil when it was nil before") + assert.Empty(t, recorder.Events, "no telemetry should be recorded") + }) + + t.Run("propagates error from original RunE", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + expectedErr := fmt.Errorf("something went wrong") + cmd := &cobra.Command{ + Use: "fail", + RunE: func(cmd *cobra.Command, args []string) error { return expectedErr }, + } + + cmdutil.RecordTelemetry(cmd, recorder) + + err := cmd.RunE(cmd, nil) + assert.ErrorIs(t, err, expectedErr) + // Telemetry is still recorded even on error + require.Len(t, recorder.Events, 1) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + }) + + t.Run("flags are sorted alphabetically", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("zebra", false, "") + cmd.Flags().Bool("alpha", false, "") + cmd.Flags().Bool("middle", false, "") + + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.Flags().Set("zebra", "true")) + require.NoError(t, cmd.Flags().Set("alpha", "true")) + require.NoError(t, cmd.Flags().Set("middle", "true")) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "alpha,middle,zebra", recorder.Events[0].Dimensions["flags"]) + }) + + t.Run("no flags set records empty flags string", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("unused", false, "") + + cmdutil.RecordTelemetry(cmd, recorder) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "", recorder.Events[0].Dimensions["flags"]) + }) + + t.Run("skips commands with telemetry disabled", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "internal", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmdutil.DisableTelemetry(cmd) + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.RunE(cmd, nil)) + assert.Empty(t, recorder.Events, "telemetry should not be recorded for disabled commands") + }) +} + +func TestRecordTelemetryForSubcommands(t *testing.T) { + t.Run("instruments nested subcommands", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + parent := &cobra.Command{Use: "pr"} + child := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(parent) + parent.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + require.NoError(t, child.RunE(child, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, "gh pr list", recorder.Events[0].Dimensions["command"]) + }) + + t.Run("skips subcommands with nil RunE", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + child := &cobra.Command{Use: "help"} // no RunE + root.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + + assert.Nil(t, child.RunE, "nil RunE should remain nil") + }) + + t.Run("skips subcommands with telemetry disabled", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + child := &cobra.Command{ + Use: "send-telemetry", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmdutil.DisableTelemetry(child) + root.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + require.NoError(t, child.RunE(child, nil)) + + assert.Empty(t, recorder.Events, "disabled commands should not record telemetry") + }) +} diff --git a/pkg/extensions/official.go b/pkg/extensions/official.go new file mode 100644 index 00000000000..dc6bdc919b0 --- /dev/null +++ b/pkg/extensions/official.go @@ -0,0 +1,53 @@ +package extensions + +import ( + "strings" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +// OfficialExtension describes a GitHub-owned CLI extension that can be +// suggested to users when they invoke an unknown command. +type OfficialExtension struct { + Name string + Owner string + Repo string +} + +// Repository returns a ghrepo.Interface pinned to github.com so that GHES +// users install from github.com rather than their enterprise host. +func (e *OfficialExtension) Repository() ghrepo.Interface { + return ghrepo.NewWithHost(e.Owner, e.Repo, "github.com") +} + +// OfficialExtensions is the registry of GitHub-owned extensions that gh will +// offer to install when the user invokes the corresponding command name. +var OfficialExtensions = []OfficialExtension{ + {Name: "aw", Owner: "github", Repo: "gh-aw"}, + {Name: "stack", Owner: "github", Repo: "gh-stack"}, +} + +// IsOfficial reports whether the given extension command name and owner +// match an entry in the OfficialExtensions registry. Owner must be +// checked alongside name because a user may have installed a third-party +// extension that happens to share a name with one of ours (e.g. +// `someuser/gh-stack` predates `github/gh-stack` becoming official). +// Owner will be empty for local extensions, in which case the extension +// is treated as non-official. +// +// Comparison is case-sensitive: on case-sensitive filesystems a user can +// install a private extension whose name differs only in casing (e.g. +// `gh-STACK`), and we must not treat that as official. Owner comparison +// is case-insensitive because GitHub usernames and organization names +// are themselves case-insensitive. +func IsOfficial(name, owner string) bool { + if owner == "" { + return false + } + for _, ext := range OfficialExtensions { + if ext.Name == name && strings.EqualFold(ext.Owner, owner) { + return true + } + } + return false +} diff --git a/pkg/extensions/official_test.go b/pkg/extensions/official_test.go new file mode 100644 index 00000000000..6d16ece2cf9 --- /dev/null +++ b/pkg/extensions/official_test.go @@ -0,0 +1,73 @@ +package extensions + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOfficialExtension_Repository(t *testing.T) { + ext := &OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + repo := ext.Repository() + assert.Equal(t, "github", repo.RepoOwner()) + assert.Equal(t, "gh-stack", repo.RepoName()) + assert.Equal(t, "github.com", repo.RepoHost()) +} + +func TestIsOfficial(t *testing.T) { + tests := []struct { + name string + extName string + extOwner string + want bool + }{ + { + name: "known official extension matches", + extName: "stack", + extOwner: "github", + want: true, + }, + { + name: "official name with different owner is not official", + extName: "stack", + extOwner: "williammartin", + want: false, + }, + { + name: "official name with empty owner is not official", + extName: "stack", + extOwner: "", + want: false, + }, + { + name: "owner comparison is case-insensitive", + extName: "stack", + extOwner: "GitHub", + want: true, + }, + { + name: "mixed-case name does not match", + extName: "STACK", + extOwner: "github", + want: false, + }, + { + name: "unknown name is not official", + extName: "not-a-real-extension", + extOwner: "github", + want: false, + }, + { + name: "empty name is not official", + extName: "", + extOwner: "github", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsOfficial(tt.extName, tt.extOwner)) + }) + } +} diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go index 60d0ece91e3..8e27e60a533 100644 --- a/pkg/iostreams/iostreams_progress_indicator_test.go +++ b/pkg/iostreams/iostreams_progress_indicator_test.go @@ -27,7 +27,7 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { // waiting for input because the console is not ready to be read. // But in this case, we are not blocking waiting for input and stdout // can be constantly read. This means the timeout will never be reached - // in the event of a expectation failure. + // in the event of an expectation failure. // To fix this, we need to implement our own timeout that is based // specifically on the total time spent reading the console and waiting // for the target string instead of the max time for a single read diff --git a/pkg/search/query.go b/pkg/search/query.go index f6e7fc05dbd..e45a4438a23 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -96,6 +96,7 @@ type Qualifiers struct { Topics string Tree string Type string + IssueType string `qualifier:"type"` Updated string User []string } @@ -234,39 +235,42 @@ func groupWithOR(qualifier string, vs []string) string { return fmt.Sprintf("(%s)", strings.Join(all, " OR ")) } +// Map turns the qualifiers into a slice-keyed map ready for query +// formatting. Multiple struct fields can share the same key when +// tagged with `qualifier:""`; in that case their values are +// concatenated under the shared key. func (q Qualifiers) Map() map[string][]string { m := map[string][]string{} v := reflect.ValueOf(q) t := reflect.TypeOf(q) for i := 0; i < v.NumField(); i++ { - fieldName := t.Field(i).Name - key := camelToKebab(fieldName) - typ := v.FieldByName(fieldName).Kind() - value := v.FieldByName(fieldName) - switch typ { + field := t.Field(i) + key := field.Tag.Get("qualifier") + if key == "" { + key = camelToKebab(field.Name) + } + value := v.Field(i) + switch value.Kind() { case reflect.Ptr: if value.IsNil() { continue } - v := reflect.Indirect(value) - m[key] = []string{fmt.Sprintf("%v", v)} + m[key] = append(m[key], fmt.Sprintf("%v", reflect.Indirect(value))) case reflect.Slice: if value.IsNil() { continue } - s := []string{} - for i := 0; i < value.Len(); i++ { - if value.Index(i).IsZero() { + for j := 0; j < value.Len(); j++ { + if value.Index(j).IsZero() { continue } - s = append(s, fmt.Sprintf("%v", value.Index(i))) + m[key] = append(m[key], fmt.Sprintf("%v", value.Index(j))) } - m[key] = s default: if value.IsZero() { continue } - m[key] = []string{fmt.Sprintf("%v", value)} + m[key] = append(m[key], fmt.Sprintf("%v", value)) } } return m diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index db6934ba0bb..1b957298083 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -308,6 +308,16 @@ func TestQualifiersMap(t *testing.T) { "user": {"user"}, }, }, + { + name: "concatenates fields that share a qualifier key", + qualifiers: Qualifiers{ + Type: "issue", + IssueType: "Bug", + }, + out: map[string][]string{ + "type": {"issue", "Bug"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md new file mode 100644 index 00000000000..e0db2feaed4 --- /dev/null +++ b/skills/gh-skill/SKILL.md @@ -0,0 +1,116 @@ +--- +name: gh-skill +description: Manage agent skills with gh skill. Use this skill to discover, preview, install, update, and publish Agent Skills so an agent can self-manage the skills available in its environment. +--- + +# Managing skills with `gh skill` + +`gh skill` installs, previews, searches, updates, and publishes +[Agent Skills](https://agentskills.io). An agent can use it to keep its +own skill set in sync with one or more GitHub repositories. + +The command is also aliased as `gh skills`. Prefer the canonical singular +`gh skill` in scripts and docs. + +## Search + +```bash +gh skill search # free-text search +gh skill search --owner # restrict to one owner +gh skill search --limit 20 --page 2 +gh skill search --json skillName,repo,description +``` + +## Preview before installing + +```bash +gh skill preview / +gh skill preview / @v1.2.0 # pin a version +``` + +## Install + +```bash +gh skill install / +gh skill install / @v1.2.0 +gh skill install / skills// # exact path, fastest +gh skill install ./local-skills-repo --from-local +``` + +`/` and `` are both required. + +Useful flags: + +- `--agent ` - target host (e.g. `github-copilot`, `claude-code`, + `cursor`, `codex`, `gemini-cli`). Repeat for multiple. Default is + `github-copilot` when non-interactive. You should know what agent you are, + so set this appropriately to install for yourself. +- `--scope project|user` - `project` (default) writes inside the current + git repo; `user` writes to the home directory and applies everywhere. +- `--pin ` - pin to a tag, branch, or commit SHA. Mutually exclusive + with `--from-local` and with inline `@version` syntax. +- `--allow-hidden-dirs` - also discover skills under dot-directories such + as `.claude/skills/`. Don't use this unless you need to, it comes with risks. +- `--force` - overwrite an existing install. + +## Update + +```bash +gh skill update --all # update every installed skill +gh skill update # update one +gh skill update --force +gh skill update --unpin # drop the pin and move to latest +``` + +## Publish + +Publishing turns a repo into a discoverable skill source. Skills are +discovered with these conventions: + +- `skills//SKILL.md` +- `skills///SKILL.md` +- `/SKILL.md` (root-level) +- `plugins//skills//SKILL.md` + +Each `SKILL.md` needs YAML frontmatter: + +```yaml +--- +name: my-skill # must equal the directory name +description: One sentence... # required, recommended <= 1024 chars +license: MIT # optional but recommended +--- +``` + +### Validate, then publish + +```bash +gh skill publish --dry-run # validate only, no release +gh skill publish --dry-run ./path/to/repo # validate a specific dir +gh skill publish --fix # auto-strip install metadata +gh skill publish --tag v1.0.0 # non-interactive publish +gh skill publish # interactive publish flow +``` + +`--fix` and `--dry-run` are mutually exclusive. `--fix` only rewrites +install-injected `metadata.github-*` keys and does not publish; commit +the result and re-run `publish`. + +The publish flow will: + +1. Add the `agent-skills` topic to the repo (so search can find it). +2. Use `--tag` (or prompt for one in a TTY). +3. Auto-push any unpushed commits. +4. Create a GitHub release with auto-generated notes. + +Always pass `--tag` so it doesn't fall through to the interactive flow. + +## Self-management pattern for agents + +A reasonable loop: + +1. `gh skill search --json skillName,repo,namespace` +2. `gh skill preview ` to inspect the `SKILL.md`. +3. `gh skill install --agent --pin ` for a + reproducible install. +4. Periodically `gh skill update --all` to refresh. \ No newline at end of file diff --git a/skills/gh/SKILL.md b/skills/gh/SKILL.md new file mode 100644 index 00000000000..34cce8bc2d8 --- /dev/null +++ b/skills/gh/SKILL.md @@ -0,0 +1,81 @@ +--- +name: gh +description: Patterns for invoking the GitHub CLI (gh) from agents. Covers structured output, pagination, repo targeting, search vs list, gh api fallback. +--- + +# Reference + +## Interactivity policy + +`gh` already does the right thing in non-TTY contexts: it skips the pager, +strips ANSI color, and errors out fast with a helpful message instead of +prompting (e.g. `must provide --title and --body when not running interactively`). +You don't need to defensively set `GH_PAGER` or pass `--no-pager` (no such +flag exists). + +## Parsing JSON + +Human output from `gh` is column-formatted. If you want structured data: + +- Add `--json field1,field2,...` for structured output. +- Run a command with `--json` and **no field list** to print the full set of + available fields, then pick what you need. +- Use `--jq ''` for filtering without piping through a separate `jq`. +- Use `--template ''` (alongside `--json`) when you want shaped + text output. Note that `--template`/`-T` collides with a body-template flag + on a few commands (e.g. `gh pr create -T`, `gh issue create -T`); always + check `--help` before assuming which one you're hitting. + +## Pagination and silent truncation + +List commands cap results. + +- `gh issue list`, `gh pr list`, `gh search ...`: pass `-L N` (`--limit N`). + The default is usually 30. +- `gh issue list` / `gh pr list` do not expose aggregate totals like + `totalCount` via `--json`. If you need a true total, use `gh api graphql` + to query `totalCount`; otherwise, treat `-L` as the cap for the current call. +- For raw API calls use `gh api --paginate `. Combine with + `--jq` and (optionally) `--slurp` to assemble one array. + +## Repo targeting + +`gh` infers the repo from the cwd's git remotes. + +Pass `--repo OWNER/REPO` (`-R`) to override the resolved CWD repo. + +## Search vs list + +- `gh search issues|prs|code|repos|commits|users` uses GitHub's search + index and accepts the full search syntax (`is:open`, `author:`, + `label:`, `repo:owner/name`, `in:title`, ...). Pass the entire query as + one quoted string, the same way you would for `--search`: + `gh search issues "is:open author:foo repo:cli/cli"`. Prefer it for + anything cross-repo or filtered by author/label. +- `gh issue list --search "..."` and `gh pr list --search "..."` accept + the same syntax but are scoped to one repo. + +## Fall back to `gh api` for anything `--json` doesn't expose + +Sometimes useful data isn't on the typed commands. Examples: + +- Review-thread comments on a PR: `gh api repos/{owner}/{repo}/pulls/{n}/comments` + (the `--comments` flag on `gh pr view` shows issue-level comments only). +- Arbitrary GraphQL: `gh api graphql -f query='...' -F var=value`. +- REST shortcuts: `gh api repos/{owner}/{repo}/...` - note the + `{owner}/{repo}` placeholder is filled in for you when run from a repo + with detected remotes; pass them literally if you want determinism. + +## Authentication + +- `gh auth status` prints the active host(s), user, and which env var (if + any) is being honored. +- `gh auth status --json` is supported. + +## Other notes + +- `gh pr checkout ` switches branches. Use `gh pr diff ` or + `gh pr view ` if you only need to read. +- `NO_COLOR`, `CLICOLOR_FORCE`, and `GH_FORCE_TTY` are honored. Set + `GH_FORCE_TTY=1` if you want TTY-style output (colors, tables, the + pager, interactivity) inside an agent harness; leave it unset unless needed.