feat(skills): support importing skills from github.com URLs (#2209)

This commit is contained in:
Multica Eve
2026-05-07 15:22:34 +08:00
committed by GitHub
parent 48e3131bf9
commit d82a2d8a04
9 changed files with 916 additions and 35 deletions

View File

@@ -48,7 +48,8 @@
"source_runtime_provider": "From {{provider}} runtime",
"source_runtime_unknown": "From a runtime",
"source_clawhub": "From ClawHub",
"source_skills_sh": "From Skills.sh"
"source_skills_sh": "From Skills.sh",
"source_github": "From GitHub"
},
"detail": {
"all_skills": "All skills",
@@ -70,6 +71,7 @@
"origin_runtime_unknown": "Local runtime",
"origin_clawhub": "Imported · ClawHub",
"origin_skills_sh": "Imported · Skills.sh",
"origin_github": "Imported · GitHub",
"origin_workspace": "Workspace",
"updated_label": "Updated {{when}}",
"by_creator": "by {{name}}"
@@ -104,6 +106,7 @@
"imported_runtime": "Imported from local runtime",
"imported_clawhub": "Imported from ClawHub",
"imported_skills_sh": "Imported from Skills.sh",
"imported_github": "Imported from GitHub",
"provider": "provider · {{provider}}"
},
"not_found": {
@@ -189,6 +192,7 @@
"importing": "Importing…",
"importing_clawhub": "Importing from ClawHub…",
"importing_skills_sh": "Importing from Skills.sh…",
"importing_github": "Importing from GitHub…",
"name_conflict_hint": " The imported skill's name already exists — delete the existing one before retrying.",
"fallback_error": "Import failed",
"cancel": "Cancel",

View File

@@ -60,7 +60,8 @@
"source_runtime_provider": "来自 {{provider}} 运行时",
"source_runtime_unknown": "来自某个运行时",
"source_clawhub": "来自 ClawHub",
"source_skills_sh": "来自 Skills.sh"
"source_skills_sh": "来自 Skills.sh",
"source_github": "来自 GitHub"
},
"detail": {
"all_skills": "全部 skill",
@@ -82,6 +83,7 @@
"origin_runtime_unknown": "本地运行时",
"origin_clawhub": "导入自 · ClawHub",
"origin_skills_sh": "导入自 · Skills.sh",
"origin_github": "导入自 · GitHub",
"origin_workspace": "工作区",
"updated_label": "{{when}}更新",
"by_creator": "由 {{name}}"
@@ -116,6 +118,7 @@
"imported_runtime": "从本地运行时导入",
"imported_clawhub": "从 ClawHub 导入",
"imported_skills_sh": "从 Skills.sh 导入",
"imported_github": "从 GitHub 导入",
"provider": "provider · {{provider}}"
},
"not_found": {
@@ -201,6 +204,7 @@
"importing": "导入中...",
"importing_clawhub": "正在从 ClawHub 导入...",
"importing_skills_sh": "正在从 Skills.sh 导入...",
"importing_github": "正在从 GitHub 导入...",
"name_conflict_hint": "导入的 skill 名称已存在——请先删除已有的再重试。",
"fallback_error": "导入失败",
"cancel": "取消",

View File

@@ -241,12 +241,13 @@ function ManualForm({
// URL import form
// ---------------------------------------------------------------------------
type DetectedSource = "clawhub" | "skills.sh" | null;
type DetectedSource = "clawhub" | "skills.sh" | "github" | null;
function detectUrlSource(url: string): DetectedSource {
const u = url.trim().toLowerCase();
if (u.includes("clawhub.ai")) return "clawhub";
if (u.includes("skills.sh")) return "skills.sh";
if (u.includes("github.com")) return "github";
return null;
}
@@ -316,6 +317,7 @@ function UrlForm({
if (!loading) return t(($) => $.create.url.import);
if (source === "clawhub") return t(($) => $.create.url.importing_clawhub);
if (source === "skills.sh") return t(($) => $.create.url.importing_skills_sh);
if (source === "github") return t(($) => $.create.url.importing_github);
return t(($) => $.create.url.importing);
})();
@@ -350,7 +352,7 @@ function UrlForm({
<p className="mb-2 text-xs text-muted-foreground">
{t(($) => $.create.url.supported_sources)}
</p>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-3 gap-2">
<SourceCard
label="ClawHub"
exampleHost="clawhub.ai/owner/skill"
@@ -363,6 +365,12 @@ function UrlForm({
browseUrl="https://skills.sh"
active={source === "skills.sh"}
/>
<SourceCard
label="GitHub"
exampleHost="github.com/owner/repo"
browseUrl="https://github.com"
active={source === "github"}
/>
</div>
</div>

View File

@@ -205,6 +205,9 @@ function SourceCell({
} else if (origin.type === "skills_sh") {
icon = <Download className="h-3 w-3 shrink-0" />;
label = t(($) => $.table.source_skills_sh);
} else if (origin.type === "github") {
icon = <Download className="h-3 w-3 shrink-0" />;
label = t(($) => $.table.source_github);
}
return (

View File

@@ -199,7 +199,9 @@ function OriginSidebarCard({
? t(($) => $.detail.origin_card.imported_runtime)
: origin.type === "clawhub"
? t(($) => $.detail.origin_card.imported_clawhub)
: t(($) => $.detail.origin_card.imported_skills_sh);
: origin.type === "github"
? t(($) => $.detail.origin_card.imported_github)
: t(($) => $.detail.origin_card.imported_skills_sh);
return (
<div className="rounded-md border bg-muted/30 p-3">
@@ -542,6 +544,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
}
if (origin.type === "clawhub") return t(($) => $.detail.subline.origin_clawhub);
if (origin.type === "skills_sh") return t(($) => $.detail.subline.origin_skills_sh);
if (origin.type === "github") return t(($) => $.detail.subline.origin_github);
return t(($) => $.detail.subline.origin_workspace);
})();

View File

@@ -3,16 +3,11 @@ import type { Skill, SkillSummary } from "@multica/core/types";
/**
* Discriminated view over `Skill.config.origin` — the JSONB blob the backend
* writes when a skill was imported from outside (local runtime, ClawHub,
* Skills.sh). Manual creates have no origin, so we synthesize `{ type:
* "manual" }` for them to keep the consumer code uniform.
*
* NOTE: the backend currently only writes `runtime_local` origins. URL
* imports leave `config.origin` empty, so `clawhub`/`skills_sh` variants are
* declared here for forward compatibility but should never be rendered in
* the UI until the server fills them in.
* Skills.sh, GitHub). Manual creates have no origin, so we synthesize
* `{ type: "manual" }` for them to keep the consumer code uniform.
*/
export type OriginInfo = {
type: "runtime_local" | "clawhub" | "skills_sh" | "manual";
type: "runtime_local" | "clawhub" | "skills_sh" | "github" | "manual";
provider?: string;
runtime_id?: string;
source_path?: string;
@@ -26,6 +21,7 @@ export function readOrigin(skill: SkillSummary): OriginInfo {
if (raw?.type === "runtime_local") return raw;
if (raw?.type === "clawhub") return raw;
if (raw?.type === "skills_sh") return raw;
if (raw?.type === "github") return raw;
return { type: "manual" };
}

View File

@@ -54,7 +54,7 @@ var skillDeleteCmd = &cobra.Command{
var skillImportCmd = &cobra.Command{
Use: "import",
Short: "Import a skill from a URL (clawhub.ai or skills.sh)",
Short: "Import a skill from a URL (clawhub.ai, skills.sh, or github.com)",
RunE: runSkillImport,
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -441,12 +442,24 @@ type ImportSkillRequest struct {
URL string `json:"url"`
}
// Per-import bundle limits. These mirror the local-runtime importer so that
// URL imports cannot smuggle in payloads that the rest of the stack would
// reject. fetchRawFile enforces the per-file cap; importedSkill.addFile
// enforces the bundle-wide caps.
const (
maxImportFileSize = 1 << 20 // 1 MiB per file
maxImportTotalSize = 8 << 20 // 8 MiB per import bundle (sum of supporting files)
maxImportFileCount = 128 // max number of supporting files
)
// importedSkill holds the data extracted from an external source.
type importedSkill struct {
name string
description string
content string // SKILL.md body
files []importedFile
bundleSize int // running sum of file content bytes for cap enforcement
origin map[string]any // written into skill.config.origin so the UI can show provenance
}
type importedFile struct {
@@ -454,6 +467,31 @@ type importedFile struct {
content string
}
// errImportCapExceeded marks an error caused by a per-file or per-bundle cap.
// Such errors must abort the import — silently dropping a file would otherwise
// produce an incomplete skill that looks valid to the user.
var errImportCapExceeded = errors.New("import cap exceeded")
// isCapError reports whether err is (or wraps) errImportCapExceeded.
func isCapError(err error) bool {
return errors.Is(err, errImportCapExceeded)
}
// addFile appends a supporting file while enforcing the per-bundle caps. It
// returns an error when either the file count or aggregate byte budget would
// be exceeded so the caller fails the import instead of silently truncating.
func (s *importedSkill) addFile(path, content string) error {
if len(s.files) >= maxImportFileCount {
return fmt.Errorf("%w: import bundle exceeds %d file limit", errImportCapExceeded, maxImportFileCount)
}
if s.bundleSize+len(content) > maxImportTotalSize {
return fmt.Errorf("%w: import bundle exceeds %d byte limit", errImportCapExceeded, maxImportTotalSize)
}
s.bundleSize += len(content)
s.files = append(s.files, importedFile{path: path, content: content})
return nil
}
// --- ClawHub types ---
type clawhubGetSkillResponse struct {
@@ -539,6 +577,7 @@ type importSource int
const (
sourceClawHub importSource = iota
sourceSkillsSh
sourceGitHub
)
// detectImportSource determines the source from a URL.
@@ -565,12 +604,14 @@ func detectImportSource(raw string) (importSource, string, error) {
return sourceSkillsSh, normalized, nil
case host == "clawhub.ai" || host == "www.clawhub.ai":
return sourceClawHub, normalized, nil
case host == "github.com" || host == "www.github.com":
return sourceGitHub, normalized, nil
default:
// If no host (bare slug), default to clawhub
if !strings.Contains(raw, "/") || !strings.Contains(raw, ".") {
return sourceClawHub, raw, nil
}
return 0, "", fmt.Errorf("unsupported source: %s (supported: clawhub.ai, skills.sh)", host)
return 0, "", fmt.Errorf("unsupported source: %s (supported: clawhub.ai, skills.sh, github.com)", host)
}
}
@@ -654,6 +695,11 @@ func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, e
result := &importedSkill{
name: chSkill.DisplayName,
description: chSkill.Summary,
origin: map[string]any{
"type": "clawhub",
"source_url": rawURL,
"slug": slug,
},
}
if result.name == "" {
result.name = slug
@@ -666,14 +712,26 @@ func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, e
}
body, err := fetchRawFile(httpClient, fileURL)
if err != nil {
// Cap violations must abort: silently dropping a file would
// produce an incomplete bundle that looks valid. SKILL.md is
// load-bearing, so any failure on it is fatal too.
if isCapError(err) || fp == "SKILL.md" {
return nil, fmt.Errorf("clawhub import: %s: %w", fp, err)
}
slog.Warn("clawhub import: file download failed", "path", fp, "error", err)
continue
}
if fp == "SKILL.md" {
result.content = string(body)
} else {
result.files = append(result.files, importedFile{path: fp, content: string(body)})
continue
}
if err := result.addFile(fp, string(body)); err != nil {
return nil, err
}
}
if result.content == "" {
return nil, fmt.Errorf("clawhub import: SKILL.md is empty or missing for %s", slug)
}
return result, nil
@@ -759,6 +817,13 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
name: name,
description: description,
content: string(skillMdBody),
origin: map[string]any{
"type": "skills_sh",
"source_url": rawURL,
"owner": owner,
"repo": repo,
"skill": skillName,
},
}
// 2. List supporting files via GitHub API
@@ -775,15 +840,15 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
var entries []githubContentEntry
if err := json.NewDecoder(dirResp.Body).Decode(&entries); err != nil {
slog.Warn("skills.sh import: failed to decode top-level directory listing", "url", apiURL, "error", err)
slog.Warn("github import: failed to decode top-level directory listing", "url", apiURL, "error", err)
return result, nil
}
// 3. Recursively collect files (excluding SKILL.md and LICENSE)
var allFiles []githubContentEntry
slog.Info("skills.sh import: collecting supporting files", "skill", skillName, "top_level_entries", len(entries))
slog.Info("github import: collecting supporting files", "skill", skillName, "top_level_entries", len(entries))
collectGitHubFiles(httpClient, entries, &allFiles, apiURL)
slog.Info("skills.sh import: collected supporting files", "skill", skillName, "files", len(allFiles))
slog.Info("github import: collected supporting files", "skill", skillName, "files", len(allFiles))
// 4. Download each file
basePath := ""
@@ -796,12 +861,17 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
}
body, err := fetchRawFile(httpClient, entry.DownloadURL)
if err != nil {
slog.Warn("skills.sh import: file download failed", "path", entry.Path, "error", err)
if isCapError(err) {
return nil, fmt.Errorf("github import: %s: %w", entry.Path, err)
}
slog.Warn("github import: file download failed", "path", entry.Path, "error", err)
continue
}
// Convert absolute GitHub path to relative path within skill
relPath := strings.TrimPrefix(entry.Path, basePath)
result.files = append(result.files, importedFile{path: relPath, content: string(body)})
if err := result.addFile(relPath, string(body)); err != nil {
return nil, err
}
}
return result, nil
@@ -836,7 +906,7 @@ func resolveGitHubSkillDirByName(httpClient *http.Client, owner, repo, defaultBr
return "", nil, skillMdNotFoundError(owner, repo, skillName)
}
slog.Warn("skills.sh import: repository tree listing truncated", "owner", owner, "repo", repo, "branch", defaultBranch)
slog.Warn("github import: repository tree listing truncated", "owner", owner, "repo", repo, "branch", defaultBranch)
if dir, body, ok := findSkillDirFromConventionalPrefixes(httpClient, owner, repo, defaultBranch, rawPrefix, skillName); ok {
return dir, body, nil
}
@@ -858,7 +928,7 @@ func collectGitHubFiles(httpClient *http.Client, entries []githubContentEntry, o
if subURL == "" {
parsed, err := url.Parse(parentURL)
if err != nil {
slog.Warn("skills.sh import: invalid parent directory url", "url", parentURL, "error", err)
slog.Warn("github import: invalid parent directory url", "url", parentURL, "error", err)
continue
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/" + entry.Name
@@ -874,13 +944,13 @@ func collectGitHubFiles(httpClient *http.Client, entries []githubContentEntry, o
if err != nil {
attrs = append(attrs, "error", err)
}
slog.Warn("skills.sh import: failed to list subdirectory", attrs...)
slog.Warn("github import: failed to list subdirectory", attrs...)
continue
}
var subEntries []githubContentEntry
if err := json.NewDecoder(subResp.Body).Decode(&subEntries); err != nil {
subResp.Body.Close()
slog.Warn("skills.sh import: failed to decode subdirectory listing", "url", subURL, "error", err)
slog.Warn("github import: failed to decode subdirectory listing", "url", subURL, "error", err)
continue
}
subResp.Body.Close()
@@ -895,7 +965,7 @@ func findSkillDirFromConventionalPrefixes(httpClient *http.Client, owner, repo,
for _, prefix := range prefixes {
paths, err := listGitHubSkillMdPaths(httpClient, owner, repo, prefix, defaultBranch)
if err != nil {
slog.Warn("skills.sh import: failed to list conventional skill prefix", "prefix", prefix, "error", err)
slog.Warn("github import: failed to list conventional skill prefix", "prefix", prefix, "error", err)
continue
}
skillPaths = append(skillPaths, paths...)
@@ -949,7 +1019,7 @@ func collectGitHubSkillMdPaths(httpClient *http.Client, entries []githubContentE
if subURL == "" {
parsed, err := url.Parse(parentURL)
if err != nil {
slog.Warn("skills.sh import: invalid parent directory url", "url", parentURL, "error", err)
slog.Warn("github import: invalid parent directory url", "url", parentURL, "error", err)
continue
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/" + entry.Name
@@ -966,14 +1036,14 @@ func collectGitHubSkillMdPaths(httpClient *http.Client, entries []githubContentE
if err != nil {
attrs = append(attrs, "error", err)
}
slog.Warn("skills.sh import: failed to list skill metadata subdirectory", attrs...)
slog.Warn("github import: failed to list skill metadata subdirectory", attrs...)
continue
}
var subEntries []githubContentEntry
if err := json.NewDecoder(subResp.Body).Decode(&subEntries); err != nil {
subResp.Body.Close()
slog.Warn("skills.sh import: failed to decode skill metadata subdirectory", "url", subURL, "error", err)
slog.Warn("github import: failed to decode skill metadata subdirectory", "url", subURL, "error", err)
continue
}
subResp.Body.Close()
@@ -1007,7 +1077,7 @@ func findMatchingSkillDirByFrontmatter(httpClient *http.Client, rawPrefix, skill
for _, skillPath := range skillPaths {
body, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, skillPath))
if err != nil {
slog.Warn("skills.sh import: fallback SKILL.md fetch failed", "path", skillPath, "error", err)
slog.Warn("github import: fallback SKILL.md fetch failed", "path", skillPath, "error", err)
continue
}
name, _ := parseSkillFrontmatter(string(body))
@@ -1080,9 +1150,272 @@ func parseSkillFrontmatter(content string) (name, description string) {
return name, description
}
// --- GitHub import ---
// githubSpec captures the parsed components of a github.com URL pointing at a
// skill (or single-skill repository).
type githubSpec struct {
owner string
repo string
ref string // empty → caller resolves the default branch
skillDir string // relative directory within the repo, "" for the repository root
// refSegments holds the raw path segments after /tree/ or /blob/ that
// jointly encode (ref, skillDir). GitHub's web URLs do not delimit the
// boundary between branch/tag name and in-repo path, so when a ref
// contains '/' (e.g. "release/v2") segments[0] alone is not the ref.
// fetchFromGitHub uses resolveGitHubRefAndPath to walk these segments
// and ask the API which prefix is a real branch/tag/commit. When this
// slice is empty, ref/skillDir above are authoritative (root URL).
refSegments []string
// kind is "tree" or "blob"; "" for root URLs. blob requires the last
// segment to be SKILL.md, which is already stripped from refSegments.
kind string
}
// parseGitHubURL extracts the owner, repo, and the raw post-/tree|/blob
// segments from a github.com URL. Supported forms:
//
// github.com/{owner}/{repo} → root, default branch
// github.com/{owner}/{repo}/tree/{ref}/{path...} → ref / skill dir
// github.com/{owner}/{repo}/blob/{ref}/{path.../SKILL.md} → ref / skill dir
//
// A simple-ref shortcut (segments[0] is the ref, the rest is the path) is
// stored in spec.ref/spec.skillDir; refSegments is also populated so that
// fetchFromGitHub can disambiguate refs containing '/' against the API.
func parseGitHubURL(raw string) (githubSpec, error) {
parsed, err := url.Parse(raw)
if err != nil {
return githubSpec{}, fmt.Errorf("invalid URL: %w", err)
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return githubSpec{}, fmt.Errorf("expected URL format: github.com/{owner}/{repo}[/tree/{ref}/{path}], got: %s", parsed.Path)
}
spec := githubSpec{owner: parts[0], repo: strings.TrimSuffix(parts[1], ".git")}
if len(parts) == 2 {
return spec, nil
}
kind := parts[2]
if kind != "tree" && kind != "blob" {
return githubSpec{}, fmt.Errorf("unsupported URL form: github.com/%s/%s/%s/... (use /tree/{ref}/... or /blob/{ref}/.../SKILL.md)", spec.owner, spec.repo, kind)
}
if len(parts) < 4 || parts[3] == "" {
return githubSpec{}, fmt.Errorf("missing ref after /%s/", kind)
}
spec.kind = kind
rest := parts[3:]
if kind == "blob" {
if !strings.EqualFold(rest[len(rest)-1], "SKILL.md") {
return githubSpec{}, fmt.Errorf("blob URL must point to a SKILL.md file")
}
rest = rest[:len(rest)-1]
if len(rest) == 0 {
return githubSpec{}, fmt.Errorf("missing ref after /blob/")
}
}
// Decode URL-escaped segments (e.g. spaces) so paths match the repo's
// real on-disk layout. Re-escaping happens in buildRawGitHubURL.
decoded := make([]string, len(rest))
for i, p := range rest {
d, err := url.PathUnescape(p)
if err != nil {
return githubSpec{}, fmt.Errorf("invalid path segment %q: %w", p, err)
}
if d == "" {
return githubSpec{}, fmt.Errorf("empty path segment in URL")
}
decoded[i] = d
}
spec.refSegments = decoded
// Optimistic split: assume the simple case where the ref is one segment.
// fetchFromGitHub will re-resolve via the API and overwrite both fields
// when the optimistic guess does not validate (e.g. release/v2 refs).
spec.ref = decoded[0]
if len(decoded) > 1 {
spec.skillDir = strings.Join(decoded[1:], "/")
}
return spec, nil
}
// resolveGitHubRefAndPath walks the parsed refSegments and asks the GitHub
// commits API which prefix corresponds to a real branch, tag, or commit.
// This is what makes refs containing '/' (e.g. "release/v2") work correctly:
// the URL github.com/o/r/tree/release/v2/skills/foo is ambiguous between
// (ref=release, path=v2/skills/foo) and (ref=release/v2, path=skills/foo),
// so we probe /repos/{o}/{r}/commits/{candidate} from longest to shortest
// and accept the first one the server confirms exists.
//
// On success spec.ref and spec.skillDir are overwritten with the resolved
// pair. On failure (no candidate resolves) a single error is returned that
// names every candidate that was tried.
func resolveGitHubRefAndPath(httpClient *http.Client, spec *githubSpec) error {
if len(spec.refSegments) == 0 {
return nil
}
// Try longest prefix first so that release/v2 wins over release.
tried := make([]string, 0, len(spec.refSegments))
for n := len(spec.refSegments); n >= 1; n-- {
candidate := strings.Join(spec.refSegments[:n], "/")
tried = append(tried, candidate)
ok, err := githubRefExists(httpClient, spec.owner, spec.repo, candidate)
if err != nil {
// Network / transport errors should not be silently treated as
// "ref does not exist" — surface them so the caller can retry.
return fmt.Errorf("validating ref %q: %w", candidate, err)
}
if ok {
spec.ref = candidate
if n == len(spec.refSegments) {
spec.skillDir = ""
} else {
spec.skillDir = strings.Join(spec.refSegments[n:], "/")
}
return nil
}
}
return fmt.Errorf("could not resolve ref in github.com/%s/%s URL — tried: %s. Make sure the branch, tag, or commit exists and that the URL is the canonical /tree/{ref}/{path} or /blob/{ref}/{path}/SKILL.md form",
spec.owner, spec.repo, strings.Join(tried, ", "))
}
// githubRefExists returns true when GitHub recognizes ref as a branch, tag,
// or commit SHA on owner/repo. It uses the commits endpoint because that
// single call accepts all three ref kinds (unlike /branches or /tags which
// only match one). 404 means the ref does not exist; any other non-200
// status is treated as an error so the caller can distinguish "missing"
// from "API down".
func githubRefExists(httpClient *http.Client, owner, repo, ref string) (bool, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s",
url.PathEscape(owner), url.PathEscape(repo), escapeRefPath(ref))
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return false, err
}
// Per GitHub docs: Accept: application/vnd.github.v3.sha returns just
// the SHA when the ref resolves, which is the cheapest possible probe.
req.Header.Set("Accept", "application/vnd.github.v3.sha")
resp, err := httpClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound, http.StatusUnprocessableEntity:
return false, nil
default:
return false, fmt.Errorf("github API returned status %d for ref %q", resp.StatusCode, ref)
}
}
func fetchFromGitHub(httpClient *http.Client, rawURL string) (*importedSkill, error) {
spec, err := parseGitHubURL(rawURL)
if err != nil {
return nil, err
}
if len(spec.refSegments) > 0 {
// Disambiguate slash-bearing refs (release/v2 etc.) against the API
// before issuing any raw or contents requests.
if err := resolveGitHubRefAndPath(httpClient, &spec); err != nil {
return nil, err
}
}
if spec.ref == "" {
spec.ref = fetchGitHubDefaultBranch(httpClient, spec.owner, spec.repo)
}
rawPrefix := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s",
url.PathEscape(spec.owner), url.PathEscape(spec.repo), escapeRefPath(spec.ref))
skillMdPath := "SKILL.md"
if spec.skillDir != "" {
skillMdPath = spec.skillDir + "/SKILL.md"
}
skillMdBody, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, skillMdPath))
if err != nil {
if spec.skillDir == "" {
return nil, fmt.Errorf("SKILL.md not found at the root of %s/%s@%s. For multi-skill repositories, point to a specific directory using github.com/%s/%s/tree/%s/<skill-dir>",
spec.owner, spec.repo, spec.ref, spec.owner, spec.repo, spec.ref)
}
return nil, fmt.Errorf("SKILL.md not found at %s in %s/%s@%s: %w",
skillMdPath, spec.owner, spec.repo, spec.ref, err)
}
name, description := parseSkillFrontmatter(string(skillMdBody))
if name == "" {
if spec.skillDir != "" {
name = filepath.Base(spec.skillDir)
} else {
name = spec.repo
}
}
result := &importedSkill{
name: name,
description: description,
content: string(skillMdBody),
origin: map[string]any{
"type": "github",
"source_url": rawURL,
"owner": spec.owner,
"repo": spec.repo,
"ref": spec.ref,
"path": spec.skillDir,
},
}
apiURL := buildGitHubContentsURL(spec.owner, spec.repo, spec.skillDir, spec.ref)
dirResp, err := httpClient.Get(apiURL)
if err != nil || dirResp.StatusCode != http.StatusOK {
// Cannot list the directory — return what we have (SKILL.md only).
// Keep this lenient: a private rate-limited request shouldn't fail
// an import that has already produced a valid SKILL.md.
if dirResp != nil {
dirResp.Body.Close()
}
return result, nil
}
defer dirResp.Body.Close()
var entries []githubContentEntry
if err := json.NewDecoder(dirResp.Body).Decode(&entries); err != nil {
slog.Warn("github import: failed to decode top-level directory listing", "url", apiURL, "error", err)
return result, nil
}
var allFiles []githubContentEntry
collectGitHubFiles(httpClient, entries, &allFiles, apiURL)
basePath := ""
if spec.skillDir != "" {
basePath = spec.skillDir + "/"
}
for _, entry := range allFiles {
if entry.DownloadURL == "" {
continue
}
body, err := fetchRawFile(httpClient, entry.DownloadURL)
if err != nil {
if isCapError(err) {
return nil, fmt.Errorf("github import: %s: %w", entry.Path, err)
}
slog.Warn("github import: file download failed", "path", entry.Path, "error", err)
continue
}
relPath := strings.TrimPrefix(entry.Path, basePath)
if err := result.addFile(relPath, string(body)); err != nil {
return nil, err
}
}
return result, nil
}
// --- Shared helpers ---
// fetchRawFile downloads a URL and returns the body bytes. Limit 1MB.
// fetchRawFile downloads a URL and returns the body bytes. Returns an error
// if the response exceeds maxImportFileSize so we never silently truncate a
// half-downloaded skill file into the workspace.
func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
resp, err := httpClient.Get(fileURL)
if err != nil {
@@ -1092,7 +1425,26 @@ func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
body, err := io.ReadAll(io.LimitReader(resp.Body, maxImportFileSize+1))
if err != nil {
return nil, err
}
if len(body) > maxImportFileSize {
return nil, fmt.Errorf("%w: file exceeds %d byte limit", errImportCapExceeded, maxImportFileSize)
}
return body, nil
}
// escapeRefPath percent-encodes each segment of a git ref individually so
// that slash-bearing refs like "release/v2" are sent to GitHub as
// "release/v2" (path separators preserved) rather than "release%2Fv2"
// (which GitHub does not accept on the commits / raw endpoints).
func escapeRefPath(ref string) string {
parts := strings.Split(ref, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
func buildRawGitHubURL(rawPrefix, repoPath string) string {
@@ -1165,6 +1517,8 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
imported, err = fetchFromClawHub(httpClient, normalized)
case sourceSkillsSh:
imported, err = fetchFromSkillsSh(httpClient, normalized)
case sourceGitHub:
imported, err = fetchFromGitHub(httpClient, normalized)
}
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
@@ -1182,13 +1536,20 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
})
}
// Persist provenance into skill.config.origin so list/detail UI can show
// "Imported from GitHub / ClawHub / Skills.sh" and link back to the source.
config := map[string]any{}
if imported.origin != nil {
config["origin"] = imported.origin
}
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
WorkspaceID: workspaceUUID,
CreatorID: creatorUUID,
Name: imported.name,
Description: imported.description,
Content: imported.content,
Config: map[string]any{},
Config: config,
Files: files,
})
if err != nil {

View File

@@ -221,7 +221,7 @@ func TestFetchFromSkillsSh_LogsSubdirectoryFailures(t *testing.T) {
}
logOutput := logs.String()
if !strings.Contains(logOutput, "skills.sh import: failed to list subdirectory") {
if !strings.Contains(logOutput, "github import: failed to list subdirectory") {
t.Fatalf("expected warning log, got %q", logOutput)
}
if !strings.Contains(logOutput, "status=404") {
@@ -540,6 +540,508 @@ func TestFetchFromSkillsSh_AnthropicPptxIntegration(t *testing.T) {
}
}
// --- GitHub source tests ---
func TestParseGitHubURL(t *testing.T) {
cases := []struct {
name string
url string
want githubSpec
wantErr bool
}{
{
name: "repo root",
url: "https://github.com/acme/skill",
want: githubSpec{owner: "acme", repo: "skill"},
},
{
name: "repo root with .git suffix",
url: "https://github.com/acme/skill.git",
want: githubSpec{owner: "acme", repo: "skill"},
},
{
name: "tree URL with directory",
url: "https://github.com/anthropics/skills/tree/main/document-skills/pptx",
want: githubSpec{owner: "anthropics", repo: "skills", ref: "main", skillDir: "document-skills/pptx"},
},
{
name: "tree URL ref only",
url: "https://github.com/anthropics/skills/tree/main",
want: githubSpec{owner: "anthropics", repo: "skills", ref: "main"},
},
{
name: "blob URL pointing at SKILL.md",
url: "https://github.com/acme/skills/blob/main/skills/foo/SKILL.md",
want: githubSpec{owner: "acme", repo: "skills", ref: "main", skillDir: "skills/foo"},
},
{
name: "blob URL with URL-escaped path segment",
url: "https://github.com/acme/skills/blob/main/my%20dir/SKILL.md",
want: githubSpec{owner: "acme", repo: "skills", ref: "main", skillDir: "my dir"},
},
{
name: "blob URL not pointing at SKILL.md",
url: "https://github.com/acme/skills/blob/main/skills/foo/README.md",
wantErr: true,
},
{
name: "missing repo",
url: "https://github.com/acme",
wantErr: true,
},
{
name: "unsupported segment",
url: "https://github.com/acme/skills/issues/1",
wantErr: true,
},
{
name: "tree URL missing ref",
url: "https://github.com/acme/skills/tree/",
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseGitHubURL(tc.url)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %+v", got)
}
return
}
if err != nil {
t.Fatalf("parseGitHubURL: %v", err)
}
if got.owner != tc.want.owner || got.repo != tc.want.repo ||
got.ref != tc.want.ref || got.skillDir != tc.want.skillDir {
t.Fatalf("got %+v, want %+v", got, tc.want)
}
})
}
}
func TestDetectImportSource_RecognizesGitHub(t *testing.T) {
src, _, err := detectImportSource("https://github.com/acme/skill")
if err != nil {
t.Fatalf("detectImportSource: %v", err)
}
if src != sourceGitHub {
t.Fatalf("source = %v, want sourceGitHub", src)
}
}
func TestFetchFromGitHub_TreeURLImportsSkillDirectory(t *testing.T) {
client, requests := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
switch r.URL.Path {
case "/repos/anthropics/skills/commits/main":
w.Write([]byte("deadbeef"))
case "/repos/anthropics/skills/contents/document-skills/pptx":
if got := r.URL.Query().Get("ref"); got != "main" {
t.Fatalf("contents ref = %q, want main", got)
}
writeJSON(w, http.StatusOK, []githubContentEntry{
{
Name: "editing.md",
Path: "document-skills/pptx/editing.md",
Type: "file",
DownloadURL: "https://raw.githubusercontent.com/anthropics/skills/main/document-skills/pptx/editing.md",
},
{
Name: "scripts",
Path: "document-skills/pptx/scripts",
Type: "dir",
URL: "https://api.github.com/repos/anthropics/skills/contents/document-skills/pptx/scripts?ref=main",
},
})
case "/repos/anthropics/skills/contents/document-skills/pptx/scripts":
writeJSON(w, http.StatusOK, []githubContentEntry{
{
Name: "add_slide.py",
Path: "document-skills/pptx/scripts/add_slide.py",
Type: "file",
DownloadURL: "https://raw.githubusercontent.com/anthropics/skills/main/document-skills/pptx/scripts/add_slide.py",
},
})
default:
http.NotFound(w, r)
}
case "raw.githubusercontent.com":
switch r.URL.Path {
case "/anthropics/skills/main/document-skills/pptx/SKILL.md":
w.Write([]byte("---\nname: pptx\ndescription: presentation tools\n---\nbody"))
case "/anthropics/skills/main/document-skills/pptx/editing.md":
w.Write([]byte("editing"))
case "/anthropics/skills/main/document-skills/pptx/scripts/add_slide.py":
w.Write([]byte("print('slide')"))
default:
http.NotFound(w, r)
}
default:
http.NotFound(w, r)
}
})
result, err := fetchFromGitHub(client, "https://github.com/anthropics/skills/tree/main/document-skills/pptx")
if err != nil {
t.Fatalf("fetchFromGitHub: %v", err)
}
if result.name != "pptx" {
t.Fatalf("name = %q, want pptx", result.name)
}
if result.description != "presentation tools" {
t.Fatalf("description = %q, want presentation tools", result.description)
}
gotPaths := importedFilePaths(result.files)
wantPaths := []string{"editing.md", "scripts/add_slide.py"}
if !equalStrings(gotPaths, wantPaths) {
t.Fatalf("files = %v (must be relative to skill dir), want %v", gotPaths, wantPaths)
}
// Verify the skill-relative path scheme: we never want supporting files
// to keep the in-repo prefix (document-skills/pptx/...).
for _, f := range result.files {
if strings.HasPrefix(f.path, "document-skills/") {
t.Fatalf("supporting file %q still carries skillDir prefix", f.path)
}
}
origin := result.origin
if origin == nil || origin["type"] != "github" {
t.Fatalf("origin = %v, want type=github", origin)
}
if origin["ref"] != "main" || origin["path"] != "document-skills/pptx" {
t.Fatalf("origin ref/path mismatch: %v", origin)
}
if !containsString(*requests, "api.github.com /repos/anthropics/skills/contents/document-skills/pptx?ref=main") {
t.Fatalf("expected contents listing, got %v", *requests)
}
}
func TestFetchFromGitHub_RepoRootResolvesDefaultBranch(t *testing.T) {
client, requests := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
switch r.URL.Path {
case "/repos/alice/single-skill":
writeJSON(w, http.StatusOK, map[string]any{"default_branch": "master"})
case "/repos/alice/single-skill/contents":
if got := r.URL.Query().Get("ref"); got != "master" {
t.Fatalf("contents ref = %q, want master", got)
}
writeJSON(w, http.StatusOK, []githubContentEntry{
{
Name: "README.md",
Path: "README.md",
Type: "file",
DownloadURL: "https://raw.githubusercontent.com/alice/single-skill/master/README.md",
},
})
default:
http.NotFound(w, r)
}
case "raw.githubusercontent.com":
switch r.URL.Path {
case "/alice/single-skill/master/SKILL.md":
w.Write([]byte("---\nname: single-skill\n---\nbody"))
case "/alice/single-skill/master/README.md":
w.Write([]byte("readme"))
default:
http.NotFound(w, r)
}
default:
http.NotFound(w, r)
}
})
result, err := fetchFromGitHub(client, "https://github.com/alice/single-skill")
if err != nil {
t.Fatalf("fetchFromGitHub: %v", err)
}
if result.name != "single-skill" {
t.Fatalf("name = %q, want single-skill", result.name)
}
gotPaths := importedFilePaths(result.files)
if !equalStrings(gotPaths, []string{"README.md"}) {
t.Fatalf("files = %v", gotPaths)
}
if !containsString(*requests, "api.github.com /repos/alice/single-skill") {
t.Fatalf("expected default-branch lookup, got %v", *requests)
}
}
func TestFetchFromGitHub_RepoRootMissingSKILLmdReturnsActionableError(t *testing.T) {
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
if r.URL.Path == "/repos/alice/multi" {
writeJSON(w, http.StatusOK, map[string]any{"default_branch": "main"})
return
}
http.NotFound(w, r)
case "raw.githubusercontent.com":
http.NotFound(w, r)
default:
http.NotFound(w, r)
}
})
_, err := fetchFromGitHub(client, "https://github.com/alice/multi")
if err == nil {
t.Fatal("expected error for missing root SKILL.md")
}
if !strings.Contains(err.Error(), "tree/main/<skill-dir>") && !strings.Contains(err.Error(), "tree/main") {
t.Fatalf("error should hint at /tree/{ref}/<skill-dir>, got %q", err.Error())
}
}
func TestFetchFromGitHub_BlobURLImportsSpecificSkill(t *testing.T) {
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
switch r.URL.Path {
case "/repos/acme/skills/commits/main":
w.Write([]byte("deadbeef"))
case "/repos/acme/skills/contents/skills/foo":
writeJSON(w, http.StatusOK, []githubContentEntry{})
default:
http.NotFound(w, r)
}
case "raw.githubusercontent.com":
if r.URL.Path == "/acme/skills/main/skills/foo/SKILL.md" {
w.Write([]byte("---\nname: foo\n---\nbody"))
return
}
http.NotFound(w, r)
default:
http.NotFound(w, r)
}
})
result, err := fetchFromGitHub(client, "https://github.com/acme/skills/blob/main/skills/foo/SKILL.md")
if err != nil {
t.Fatalf("fetchFromGitHub: %v", err)
}
if result.name != "foo" {
t.Fatalf("name = %q, want foo", result.name)
}
if result.origin["path"] != "skills/foo" {
t.Fatalf("origin path = %v, want skills/foo", result.origin["path"])
}
}
// --- Bundle / file size cap tests ---
func TestFetchRawFile_ReturnsErrorOnOversizedFile(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(bytes.Repeat([]byte("a"), maxImportFileSize+1024))
}))
t.Cleanup(server.Close)
_, err := fetchRawFile(&http.Client{}, server.URL+"/big.bin")
if err == nil {
t.Fatal("expected error for oversized file, got nil")
}
if !strings.Contains(err.Error(), "byte limit") {
t.Fatalf("error = %q, want byte limit message", err.Error())
}
if !isCapError(err) {
t.Fatalf("error %q must be classified as a cap error so callers fail-fast", err.Error())
}
}
func TestImportedSkill_AddFileEnforcesBundleLimits(t *testing.T) {
t.Run("file count", func(t *testing.T) {
s := &importedSkill{}
for i := 0; i < maxImportFileCount; i++ {
if err := s.addFile("f", "x"); err != nil {
t.Fatalf("addFile %d: %v", i, err)
}
}
err := s.addFile("overflow", "x")
if err == nil {
t.Fatal("expected file count cap error")
}
if !isCapError(err) {
t.Fatalf("error %q must be a cap error", err.Error())
}
})
t.Run("total bytes", func(t *testing.T) {
s := &importedSkill{}
big := strings.Repeat("y", maxImportTotalSize)
if err := s.addFile("a", big); err != nil {
t.Fatalf("addFile at cap: %v", err)
}
err := s.addFile("b", "x")
if err == nil {
t.Fatal("expected total bytes cap error")
}
if !isCapError(err) {
t.Fatalf("error %q must be a cap error", err.Error())
}
})
}
// fetchFromGitHub must FAIL the import (not just log+continue) when a
// supporting file exceeds the per-file cap — silently dropping the file
// would leave a skill bundle that looks valid to the user but is missing
// content.
func TestFetchFromGitHub_OversizedSupportingFileFailsImport(t *testing.T) {
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
switch r.URL.Path {
case "/repos/acme/skills/commits/main":
w.Write([]byte("deadbeef"))
case "/repos/acme/skills/contents/foo":
writeJSON(w, http.StatusOK, []githubContentEntry{
{
Name: "huge.bin",
Path: "foo/huge.bin",
Type: "file",
DownloadURL: "https://raw.githubusercontent.com/acme/skills/main/foo/huge.bin",
},
})
default:
http.NotFound(w, r)
}
case "raw.githubusercontent.com":
switch r.URL.Path {
case "/acme/skills/main/foo/SKILL.md":
w.Write([]byte("---\nname: foo\n---\nbody"))
case "/acme/skills/main/foo/huge.bin":
w.Write(bytes.Repeat([]byte("z"), maxImportFileSize+512))
default:
http.NotFound(w, r)
}
default:
http.NotFound(w, r)
}
})
_, err := fetchFromGitHub(client, "https://github.com/acme/skills/tree/main/foo")
if err == nil {
t.Fatal("expected oversized supporting file to fail the whole import")
}
if !strings.Contains(err.Error(), "huge.bin") || !strings.Contains(err.Error(), "byte limit") {
t.Fatalf("error %q should name the file and the cap", err.Error())
}
}
// fetchFromSkillsSh has the same supporting-file loop and must also fail
// (not just warn) when one of those files exceeds the cap.
func TestFetchFromSkillsSh_OversizedSupportingFileFailsImport(t *testing.T) {
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
switch r.URL.Path {
case "/repos/acme/skills":
writeJSON(w, http.StatusOK, map[string]any{"default_branch": "main"})
case "/repos/acme/skills/contents/skills/foo":
writeJSON(w, http.StatusOK, []githubContentEntry{
{
Name: "huge.bin",
Path: "skills/foo/huge.bin",
Type: "file",
DownloadURL: "https://raw.githubusercontent.com/acme/skills/main/skills/foo/huge.bin",
},
})
default:
http.NotFound(w, r)
}
case "raw.githubusercontent.com":
switch r.URL.Path {
case "/acme/skills/main/skills/foo/SKILL.md":
w.Write([]byte("---\nname: foo\n---\nbody"))
case "/acme/skills/main/skills/foo/huge.bin":
w.Write(bytes.Repeat([]byte("z"), maxImportFileSize+512))
default:
http.NotFound(w, r)
}
default:
http.NotFound(w, r)
}
})
_, err := fetchFromSkillsSh(client, "https://skills.sh/acme/skills/foo")
if err == nil {
t.Fatal("expected oversized supporting file to fail the whole import")
}
if !strings.Contains(err.Error(), "huge.bin") {
t.Fatalf("error %q should name the offending file", err.Error())
}
}
// Slash-bearing refs (e.g. release/v2) are now resolved against the API
// instead of being silently parsed as ref="release", path="v2/...". The
// resolver must walk longest→shortest and pick the prefix the API
// confirms exists.
func TestFetchFromGitHub_ResolvesSlashRefAgainstAPI(t *testing.T) {
client, requests := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
switch r.URL.Path {
case "/repos/acme/skills/commits/release/v2/skills/foo",
"/repos/acme/skills/commits/release/v2/skills":
http.NotFound(w, r)
case "/repos/acme/skills/commits/release/v2":
w.Write([]byte("deadbeef"))
case "/repos/acme/skills/contents/skills/foo":
if got := r.URL.Query().Get("ref"); got != "release/v2" {
t.Fatalf("contents called with ref=%q, want release/v2", got)
}
writeJSON(w, http.StatusOK, []githubContentEntry{})
default:
http.NotFound(w, r)
}
case "raw.githubusercontent.com":
switch r.URL.Path {
case "/acme/skills/release/v2/skills/foo/SKILL.md":
w.Write([]byte("---\nname: foo\n---\nbody"))
default:
http.NotFound(w, r)
}
default:
http.NotFound(w, r)
}
})
result, err := fetchFromGitHub(client, "https://github.com/acme/skills/tree/release/v2/skills/foo")
if err != nil {
t.Fatalf("fetchFromGitHub: %v", err)
}
if result.origin["ref"] != "release/v2" {
t.Fatalf("origin ref = %v, want release/v2", result.origin["ref"])
}
if result.origin["path"] != "skills/foo" {
t.Fatalf("origin path = %v, want skills/foo", result.origin["path"])
}
// Sanity-check that the resolver actually probed in the expected order.
if !containsString(*requests, "api.github.com /repos/acme/skills/commits/release/v2/skills/foo") {
t.Fatalf("resolver should probe longest prefix first, requests=%v", *requests)
}
}
// When none of the candidate refs resolve, fail with a clear error that
// names what was tried — do not silently fall back to using the first
// segment as the ref (the previous behavior, which would import the wrong
// branch / wrong path).
func TestFetchFromGitHub_UnresolvableRefFailsLoudly(t *testing.T) {
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("X-Test-Original-Host") {
case "api.github.com":
http.NotFound(w, r)
case "raw.githubusercontent.com":
t.Fatalf("must not hit raw.githubusercontent.com when ref unresolved: %s", r.URL.Path)
default:
http.NotFound(w, r)
}
})
_, err := fetchFromGitHub(client, "https://github.com/acme/skills/tree/nope/skills/foo")
if err == nil {
t.Fatal("expected error when no candidate ref resolves")
}
if !strings.Contains(err.Error(), "could not resolve ref") {
t.Fatalf("error %q should mention ref resolution failure", err.Error())
}
}
type rewriteGitHubTransport struct {
target *url.URL
base http.RoundTripper