From 19c8f9e32825b45fa0802824c41e7dfbb6234dab Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 26 Mar 2026 11:00:23 -0400 Subject: [PATCH] feat: add GitHub API query tools and per-tool PII exemptions Add 6 new read-only GitHub API tools to the github skill: github_list_prs, github_get_user, github_list_stargazers, github_list_forks, github_pr_author_profiles, and github_stargazer_profiles. All list tools support pagination via page/per_page parameters. Add allow_tools config to the guardrail engine so specific tools can bypass PII checks when returning public profile data. Update error messages to identify which guardrail triggered (no_pii vs no_secrets). Pre-configure the default policy scaffold with exemptions for GitHub profile tools and write tools that echo allowed content. --- docs/hooks.md | 2 +- docs/security/guardrails.md | 37 ++++- docs/skills.md | 14 +- forge-cli/runtime/guardrails_loader.go | 14 +- forge-cli/runtime/runner.go | 2 +- forge-core/runtime/guardrails.go | 37 ++++- forge-core/runtime/guardrails_test.go | 92 ++++++++++++- forge-core/runtime/loop.go | 4 +- forge-core/runtime/loop_test.go | 6 + forge-skills/local/embedded/github/SKILL.md | 48 +++++++ .../github/scripts/github-get-user.sh | 46 +++++++ .../github/scripts/github-list-forks.sh | 89 ++++++++++++ .../github/scripts/github-list-prs.sh | 94 +++++++++++++ .../github/scripts/github-list-stargazers.sh | 82 +++++++++++ .../scripts/github-pr-author-profiles.sh | 127 ++++++++++++++++++ .../scripts/github-stargazer-profiles.sh | 119 ++++++++++++++++ 16 files changed, 798 insertions(+), 15 deletions(-) create mode 100755 forge-skills/local/embedded/github/scripts/github-get-user.sh create mode 100755 forge-skills/local/embedded/github/scripts/github-list-forks.sh create mode 100755 forge-skills/local/embedded/github/scripts/github-list-prs.sh create mode 100755 forge-skills/local/embedded/github/scripts/github-list-stargazers.sh create mode 100755 forge-skills/local/embedded/github/scripts/github-pr-author-profiles.sh create mode 100755 forge-skills/local/embedded/github/scripts/github-stargazer-profiles.sh diff --git a/docs/hooks.md b/docs/hooks.md index c6f96b7..3efb880 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -77,7 +77,7 @@ hooks.Register(engine.BeforeToolExec, func(ctx context.Context, hctx *engine.Hoo `AfterToolExec` hooks can modify `hctx.ToolOutput` to redact sensitive content before it enters the LLM context. The agent loop reads back `ToolOutput` from the `HookContext` after all hooks fire. -The runner registers a guardrail hook that scans tool output for secrets and PII patterns. See [Tool Output Scanning](security/guardrails.md#tool-output-scanning) for details. +The runner registers a guardrail hook that scans tool output for secrets and PII patterns. The hook passes `hctx.ToolName` to the guardrail engine, enabling per-tool exemptions via `allow_tools` config. See [Tool Output Scanning](security/guardrails.md#tool-output-scanning) for details. ```go hooks.Register(engine.AfterToolExec, func(ctx context.Context, hctx *engine.HookContext) error { diff --git a/docs/security/guardrails.md b/docs/security/guardrails.md index 61da0d8..0ab1a03 100644 --- a/docs/security/guardrails.md +++ b/docs/security/guardrails.md @@ -75,7 +75,7 @@ All four built-in guardrails (`content_filter`, `no_pii`, `jailbreak_protection` ## Tool Output Scanning -The guardrail engine scans tool output via an `AfterToolExec` hook, catching secrets and PII before they enter the LLM context or outbound messages. +The guardrail engine scans tool output via an `AfterToolExec` hook, catching secrets and PII before they enter the LLM context or outbound messages. The hook passes the tool name to enable per-tool exemptions (see [Per-Tool PII Exemptions](#per-tool-pii-exemptions) below). | Guardrail | What it detects in tool output | |-----------|-------------------------------| @@ -86,11 +86,44 @@ The guardrail engine scans tool output via an `AfterToolExec` hook, catching sec | Mode | Behavior | |------|----------| -| `enforce` | Returns a generic error (`"tool output blocked by content policy"`), blocking the result from entering the LLM context. The error message intentionally omits which guardrail matched to avoid leaking security internals to the LLM or channel. | +| `enforce` | Returns an error identifying the guardrail that triggered (e.g., `"tool output blocked by no_pii guardrail (PII detected in output)"`), blocking the result from entering the LLM context. | | `warn` | Replaces matched patterns with `[REDACTED]`, logs a warning, and allows the redacted output through | The hook writes the redacted text back to `HookContext.ToolOutput`, which the agent loop reads after all hooks fire. This is backwards-compatible — existing hooks that don't modify `ToolOutput` leave it unchanged. +### Per-Tool PII Exemptions + +Some tools legitimately return PII as part of their function (e.g., `github_get_user` returning public email addresses). The `allow_tools` config option lets specific tools bypass a guardrail entirely. + +```json +{ + "guardrails": [ + { + "type": "no_pii", + "config": { + "allow_tools": [ + "github_get_user", + "github_pr_author_profiles", + "github_stargazer_profiles", + "file_create", + "code_agent_write", + "code_agent_edit" + ] + } + } + ] +} +``` + +**Key behaviors:** + +| Behavior | Detail | +|----------|--------| +| Per-guardrail scope | `allow_tools` on `no_pii` does **not** bypass `no_secrets` — each guardrail has its own allowlist | +| Write tools included | `file_create`, `code_agent_write`, and `code_agent_edit` are included because they echo back content the LLM already has — blocking the echo is redundant | +| Default config | The default policy scaffold pre-configures `allow_tools` for GitHub profile tools and write tools | +| Custom overrides | Override via `policy-scaffold.json` to add or remove tools from the allowlist | + ## Path Containment The `cli_execute` tool confines filesystem path arguments to the agent's working directory. This prevents social-engineering attacks where an LLM is tricked into listing or reading files outside the project. diff --git a/docs/skills.md b/docs/skills.md index 7fa3f03..0dbdc54 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -163,7 +163,7 @@ forge skills list --tags kubernetes,incident-response | Skill | Icon | Category | Description | Scripts | |-------|------|----------|-------------|---------| -| `github` | 🐙 | developer | Clone repos, create issues/PRs, and manage git workflows | `github-clone.sh`, `github-checkout.sh`, `github-commit.sh`, `github-push.sh`, `github-create-pr.sh`, `github-status.sh` | +| `github` | 🐙 | developer | Clone repos, create issues/PRs, query GitHub API, and manage git workflows | `github-clone.sh`, `github-checkout.sh`, `github-commit.sh`, `github-push.sh`, `github-create-pr.sh`, `github-status.sh`, `github-list-prs.sh`, `github-get-user.sh`, `github-list-stargazers.sh`, `github-list-forks.sh`, `github-pr-author-profiles.sh`, `github-stargazer-profiles.sh` | | `code-agent` | 🤖 | developer | Autonomous code generation, modification, and project scaffolding | — (builtin tools) | | `weather` | 🌤️ | utilities | Get weather data for a location | — (binary-backed) | | `tavily-search` | 🔍 | research | Search the web using Tavily AI search API | `tavily-search.sh` | @@ -365,7 +365,7 @@ The `github` skill provides a complete git + GitHub workflow through script-back forge skills add github ``` -This registers eight tools: +This registers fourteen tools: | Tool | Purpose | |------|---------| @@ -377,9 +377,19 @@ This registers eight tools: | `github_create_pr` | Create a pull request | | `github_create_issue` | Create a GitHub issue | | `github_list_issues` | List open issues for a repository | +| `github_list_prs` | List pull requests with state filter and pagination | +| `github_get_user` | Get a GitHub user's public profile | +| `github_list_stargazers` | List stargazers for a repository with pagination | +| `github_list_forks` | List forks of a repository with pagination | +| `github_pr_author_profiles` | List PR authors and fetch their full profiles (compound 2-step) | +| `github_stargazer_profiles` | List stargazers and fetch their full profiles (compound 2-step) | **Workflow:** Clone → explore → edit → status → commit → push → create PR. The skill's system prompt enforces this sequence and prevents raw `git` commands via `cli_execute`. +**Pagination:** List tools (`github_list_prs`, `github_list_stargazers`, `github_list_forks`, `github_pr_author_profiles`, `github_stargazer_profiles`) support `page` (1-based) and `per_page` (default 30, max 100) parameters. Responses include `pagination.has_next_page` to indicate more results are available. + +**PII exemption:** Profile-returning tools (`github_get_user`, `github_pr_author_profiles`, `github_stargazer_profiles`) are pre-configured in the default policy scaffold's `no_pii` `allow_tools` list, so they can return public profile data (emails, bios) without triggering PII guardrails. See [Per-Tool PII Exemptions](security/guardrails.md#per-tool-pii-exemptions). + Requires: `gh`, `git`, `jq`. Optional: `GH_TOKEN`. Egress: `api.github.com`, `github.com`. ### Code-Agent Skill diff --git a/forge-cli/runtime/guardrails_loader.go b/forge-cli/runtime/guardrails_loader.go index cadd4cb..c02909a 100644 --- a/forge-cli/runtime/guardrails_loader.go +++ b/forge-cli/runtime/guardrails_loader.go @@ -36,7 +36,19 @@ func DefaultPolicyScaffold() *agentspec.PolicyScaffold { Type: "content_filter", Config: map[string]any{"enabled": true}, }, - {Type: "no_pii"}, + { + Type: "no_pii", + Config: map[string]any{ + "allow_tools": []any{ + "github_get_user", + "github_pr_author_profiles", + "github_stargazer_profiles", + "file_create", + "code_agent_write", + "code_agent_edit", + }, + }, + }, {Type: "jailbreak_protection"}, {Type: "no_secrets"}, }, diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index 3ab85ae..ab67f4a 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -1474,7 +1474,7 @@ func (r *Runner) registerGuardrailHooks(hooks *coreruntime.HookRegistry, guardra if hctx.ToolOutput == "" { return nil } - redacted, err := guardrails.CheckToolOutput(hctx.ToolOutput) + redacted, err := guardrails.CheckToolOutput(hctx.ToolName, hctx.ToolOutput) if err != nil { return err } diff --git a/forge-core/runtime/guardrails.go b/forge-core/runtime/guardrails.go index 8ebb857..c374d08 100644 --- a/forge-core/runtime/guardrails.go +++ b/forge-core/runtime/guardrails.go @@ -252,12 +252,22 @@ func (g *GuardrailEngine) checkNoSecrets(text string) error { // because tool outputs are internal (sent to the LLM, not the user) and // blocking would kill the entire agent session. Search tools routinely find // code containing API key patterns in test files, config examples, etc. -func (g *GuardrailEngine) CheckToolOutput(text string) (string, error) { +// +// The toolName parameter enables per-tool PII exemptions: if a guardrail's +// config contains "allow_tools" (a list of tool names), tools in that list +// skip the corresponding check. This lets tools like github_get_user return +// public profile data (emails, bios) without triggering PII blocks. +func (g *GuardrailEngine) CheckToolOutput(toolName, text string) (string, error) { if text == "" { return text, nil } for _, gr := range g.scaffold.Guardrails { + // Check if this tool is in the guardrail's allow_tools list. + if g.toolAllowed(toolName, gr) { + continue + } + switch gr.Type { case "no_secrets": for _, re := range secretPatterns { @@ -265,7 +275,7 @@ func (g *GuardrailEngine) CheckToolOutput(text string) (string, error) { continue } if g.enforce { - return "", fmt.Errorf("tool output blocked by content policy") + return "", fmt.Errorf("tool output blocked by no_secrets guardrail (secret/credential detected in output)") } text = re.ReplaceAllString(text, "[REDACTED]") g.logger.Warn("guardrail redaction", map[string]any{ @@ -295,7 +305,7 @@ func (g *GuardrailEngine) CheckToolOutput(text string) (string, error) { continue } if g.enforce { - return "", fmt.Errorf("tool output blocked by content policy") + return "", fmt.Errorf("tool output blocked by no_pii guardrail (PII detected in output)") } // Warn mode: redact only validated matches if p.validate != nil { @@ -322,6 +332,27 @@ func (g *GuardrailEngine) CheckToolOutput(text string) (string, error) { return text, nil } +// toolAllowed checks whether toolName is in the guardrail's "allow_tools" config list. +func (g *GuardrailEngine) toolAllowed(toolName string, gr agentspec.Guardrail) bool { + if toolName == "" || gr.Config == nil { + return false + } + allowRaw, ok := gr.Config["allow_tools"] + if !ok { + return false + } + list, ok := allowRaw.([]any) + if !ok { + return false + } + for _, v := range list { + if s, ok := v.(string); ok && s == toolName { + return true + } + } + return false +} + // --- PII Validators --- // Ported from the reference guardrails library to reduce false positives. diff --git a/forge-core/runtime/guardrails_test.go b/forge-core/runtime/guardrails_test.go index 0ec0dfa..3f1ef39 100644 --- a/forge-core/runtime/guardrails_test.go +++ b/forge-core/runtime/guardrails_test.go @@ -202,7 +202,7 @@ func TestCheckToolOutput_RedactsWithValidation(t *testing.T) { }, false, logger) // warn mode // Valid SSN should be redacted - out, err := g.CheckToolOutput("SSN is 456-78-9012") + out, err := g.CheckToolOutput("some_tool", "SSN is 456-78-9012") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -211,7 +211,7 @@ func TestCheckToolOutput_RedactsWithValidation(t *testing.T) { } // Invalid SSN (area 000) should NOT be redacted - out, err = g.CheckToolOutput("code 000-12-3456 here") + out, err = g.CheckToolOutput("some_tool", "code 000-12-3456 here") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -228,7 +228,7 @@ func TestCheckToolOutput_K8sBytesNotBlocked(t *testing.T) { // K8s memory byte counts should not trigger PII detection k8sOutput := `{"memory": "4294967296", "cpu": "2000m", "pods": "110", "allocatable_memory": "3221225472"}` - out, err := g.CheckToolOutput(k8sOutput) + out, err := g.CheckToolOutput("some_tool", k8sOutput) if err != nil { t.Fatalf("k8s output blocked as PII: %v", err) } @@ -243,10 +243,94 @@ func TestCheckToolOutput_EnforceBlocksValidPII(t *testing.T) { Guardrails: []agentspec.Guardrail{{Type: "no_pii"}}, }, true, logger) // enforce mode - _, err := g.CheckToolOutput("SSN: 456-78-9012") + _, err := g.CheckToolOutput("some_tool", "SSN: 456-78-9012") if err == nil { t.Error("expected enforce mode to block valid SSN") } + if !strings.Contains(err.Error(), "no_pii") { + t.Errorf("expected error to mention no_pii guardrail, got: %v", err) + } +} + +func TestCheckToolOutput_AllowToolsBypassesPII(t *testing.T) { + logger := &testLogger{} + g := NewGuardrailEngine(&agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{ + { + Type: "no_pii", + Config: map[string]any{ + "allow_tools": []any{"github_get_user", "github_pr_author_profiles"}, + }, + }, + }, + }, true, logger) // enforce mode + + // Allowed tool should pass through with PII + out, err := g.CheckToolOutput("github_get_user", `{"email": "user@example.com"}`) + if err != nil { + t.Fatalf("allowed tool should not be blocked: %v", err) + } + if !strings.Contains(out, "user@example.com") { + t.Error("expected email to pass through for allowed tool") + } + + // Non-allowed tool should still be blocked + _, err = g.CheckToolOutput("some_other_tool", `{"email": "user@example.com"}`) + if err == nil { + t.Error("expected non-allowed tool to be blocked for PII") + } +} + +func TestCheckToolOutput_AllowToolsOnlyAffectsConfiguredGuardrail(t *testing.T) { + logger := &testLogger{} + g := NewGuardrailEngine(&agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{ + {Type: "no_secrets"}, // no allow_tools — applies to all tools + { + Type: "no_pii", + Config: map[string]any{ + "allow_tools": []any{"github_get_user"}, + }, + }, + }, + }, true, logger) + + // Allowed tool bypasses PII but NOT secrets + _, err := g.CheckToolOutput("github_get_user", "token: ghp_abcdefghijklmnopqrstuvwxyz0123456789") + if err == nil { + t.Error("allow_tools for no_pii should not bypass no_secrets") + } + if !strings.Contains(err.Error(), "no_secrets") { + t.Errorf("expected error to mention no_secrets, got: %v", err) + } +} + +func TestCheckToolOutput_ErrorMessageMentionsGuardrailType(t *testing.T) { + logger := &testLogger{} + + // Test no_secrets error message + g := NewGuardrailEngine(&agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{{Type: "no_secrets"}}, + }, true, logger) + _, err := g.CheckToolOutput("some_tool", "key: sk-ant-abcdefghijklmnopqrstuv") + if err == nil { + t.Fatal("expected error for secret") + } + if !strings.Contains(err.Error(), "no_secrets") { + t.Errorf("expected error to mention no_secrets, got: %v", err) + } + + // Test no_pii error message + g2 := NewGuardrailEngine(&agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{{Type: "no_pii"}}, + }, true, logger) + _, err = g2.CheckToolOutput("some_tool", "email: test@example.com") + if err == nil { + t.Fatal("expected error for PII") + } + if !strings.Contains(err.Error(), "no_pii") { + t.Errorf("expected error to mention no_pii, got: %v", err) + } } // --- CheckOutbound message tests --- diff --git a/forge-core/runtime/loop.go b/forge-core/runtime/loop.go index 8ee0787..3359020 100644 --- a/forge-core/runtime/loop.go +++ b/forge-core/runtime/loop.go @@ -672,7 +672,9 @@ func toolPhase(name string) workflowPhase { switch name { case "github_clone", "code_agent_scaffold", "github_checkout": return phaseSetup - case "code_agent_read", "grep_search", "glob_search", "directory_tree", "read_skill", "github_status": + case "code_agent_read", "grep_search", "glob_search", "directory_tree", "read_skill", "github_status", + "github_list_prs", "github_get_user", "github_list_stargazers", "github_list_forks", + "github_pr_author_profiles", "github_stargazer_profiles": return phaseExplore case "code_agent_edit", "code_agent_write", "code_agent_patch", "file_create", "code_agent_run": return phaseEdit diff --git a/forge-core/runtime/loop_test.go b/forge-core/runtime/loop_test.go index a641ec9..4b85688 100644 --- a/forge-core/runtime/loop_test.go +++ b/forge-core/runtime/loop_test.go @@ -368,6 +368,12 @@ func TestToolPhaseClassification(t *testing.T) { {"directory_tree", phaseExplore}, {"read_skill", phaseExplore}, {"github_status", phaseExplore}, + {"github_list_prs", phaseExplore}, + {"github_get_user", phaseExplore}, + {"github_list_stargazers", phaseExplore}, + {"github_list_forks", phaseExplore}, + {"github_pr_author_profiles", phaseExplore}, + {"github_stargazer_profiles", phaseExplore}, {"code_agent_edit", phaseEdit}, {"code_agent_write", phaseEdit}, {"code_agent_patch", phaseEdit}, diff --git a/forge-skills/local/embedded/github/SKILL.md b/forge-skills/local/embedded/github/SKILL.md index d2a7681..99da591 100644 --- a/forge-skills/local/embedded/github/SKILL.md +++ b/forge-skills/local/embedded/github/SKILL.md @@ -8,6 +8,9 @@ tags: - pull-requests - repositories - git + - stargazers + - forks + - users description: Create issues, PRs, clone repos, and manage git workflows metadata: forge: @@ -84,6 +87,9 @@ When asked to fix a bug or make changes, you must: explore → understand → ed - `github_commit`, `github_push`, and `github_checkout` refuse to operate on main/master. - Always use `github_status` before committing to review what changed. +**Pagination:** +For tools that return lists (`github_list_prs`, `github_list_stargazers`, `github_list_forks`, `github_pr_author_profiles`, `github_stargazer_profiles`), use `page` (1-based) and `per_page` (default 30, max 100) parameters. The response includes a `pagination` object with `has_next_page` — if true, increment `page` to fetch the next batch. + ## Tool: github_clone Clone a GitHub repository and create a feature branch. @@ -139,3 +145,45 @@ Create a pull request. **Input:** repo (string), title (string), body (string), head (string), base (string) **Output:** Pull request URL + +## Tool: github_list_prs + +List pull requests for a repository with pagination. + +**Input:** repo (string: owner/repo, SSH URL, or HTTPS URL), state (string: open/closed/all, default: open), page (int, default: 1), per_page (int, default: 30, max: 100) +**Output:** `{repo, state, pull_requests: [{number, title, state, user, created_at, updated_at, head_ref, base_ref, url}], pagination: {page, per_page, count, has_next_page}}` + +## Tool: github_get_user + +Get a GitHub user's public profile information. + +**Input:** username (string: GitHub username) +**Output:** `{login, name, email, bio, company, location, blog, public_repos, followers, following, created_at, url}` + +## Tool: github_list_stargazers + +List stargazers (users who starred) for a repository with pagination. + +**Input:** repo (string: owner/repo, SSH URL, or HTTPS URL), page (int, default: 1), per_page (int, default: 30, max: 100) +**Output:** `{repo, stargazers: [{login, url}], pagination: {page, per_page, count, has_next_page}}` + +## Tool: github_list_forks + +List forks of a repository with pagination. + +**Input:** repo (string: owner/repo, SSH URL, or HTTPS URL), sort (string: newest/oldest/stargazers, default: newest), page (int, default: 1), per_page (int, default: 30, max: 100) +**Output:** `{repo, forks: [{full_name, owner, created_at, updated_at, stargazers_count, url}], pagination: {page, per_page, count, has_next_page}}` + +## Tool: github_pr_author_profiles + +List PR authors and fetch their full profiles (compound 2-step tool). First fetches PRs, then fetches the profile of each unique author. + +**Input:** repo (string: owner/repo, SSH URL, or HTTPS URL), state (string: open/closed/all, default: open), page (int, default: 1), per_page (int, default: 30, max: 100) +**Output:** `{repo, state, profiles: [{login, name, email, bio, company, location, blog, public_repos, followers, following, created_at, url, pr_count}], total_prs_scanned, unique_authors, pagination: {page, per_page, count, has_next_page}}` + +## Tool: github_stargazer_profiles + +List stargazers and fetch their full profiles (compound 2-step tool). First fetches stargazers, then fetches the profile of each unique user. + +**Input:** repo (string: owner/repo, SSH URL, or HTTPS URL), page (int, default: 1), per_page (int, default: 30, max: 100) +**Output:** `{repo, profiles: [{login, name, email, bio, company, location, blog, public_repos, followers, following, created_at, url}], total_stargazers_scanned, unique_users, pagination: {page, per_page, count, has_next_page}}` diff --git a/forge-skills/local/embedded/github/scripts/github-get-user.sh b/forge-skills/local/embedded/github/scripts/github-get-user.sh new file mode 100755 index 0000000..5eb1668 --- /dev/null +++ b/forge-skills/local/embedded/github/scripts/github-get-user.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# github-get-user.sh — Get a GitHub user profile. +# Usage: ./github-get-user.sh '{"username": "octocat"}' +# +# Requires: gh, jq +set -euo pipefail + +# --- Read input --- +INPUT="${1:-}" +if [ -z "$INPUT" ]; then + echo '{"error": "usage: github-get-user.sh {\"username\": \"...\"}"}' >&2 + exit 1 +fi +if ! printf '%s' "$INPUT" | jq empty 2>/dev/null; then + echo '{"error": "invalid JSON input"}' >&2 + exit 1 +fi + +USERNAME=$(printf '%s' "$INPUT" | jq -r '.username // empty') + +if [ -z "$USERNAME" ]; then + echo '{"error": "username is required"}' >&2 + exit 1 +fi + +# --- Fetch user profile --- +RESPONSE=$(gh api "users/${USERNAME}" 2>&1) || { + echo "{\"error\": \"GitHub API call failed: $RESPONSE\"}" >&2 + exit 1 +} + +# --- Parse and output --- +printf '%s' "$RESPONSE" | jq '{ + login: .login, + name: .name, + email: .email, + bio: .bio, + company: .company, + location: .location, + blog: .blog, + public_repos: .public_repos, + followers: .followers, + following: .following, + created_at: .created_at, + url: .html_url +}' diff --git a/forge-skills/local/embedded/github/scripts/github-list-forks.sh b/forge-skills/local/embedded/github/scripts/github-list-forks.sh new file mode 100755 index 0000000..a4158a9 --- /dev/null +++ b/forge-skills/local/embedded/github/scripts/github-list-forks.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# github-list-forks.sh — List forks for a GitHub repository. +# Usage: ./github-list-forks.sh '{"repo": "owner/repo", "sort": "newest", "page": 1, "per_page": 30}' +# +# Requires: gh, jq +set -euo pipefail + +# --- Read input --- +INPUT="${1:-}" +if [ -z "$INPUT" ]; then + echo '{"error": "usage: github-list-forks.sh {\"repo\": \"...\", \"sort\": \"newest\", \"page\": 1, \"per_page\": 30}"}' >&2 + exit 1 +fi +if ! printf '%s' "$INPUT" | jq empty 2>/dev/null; then + echo '{"error": "invalid JSON input"}' >&2 + exit 1 +fi + +REPO=$(printf '%s' "$INPUT" | jq -r '.repo // empty') +SORT=$(printf '%s' "$INPUT" | jq -r '.sort // empty') +PAGE=$(printf '%s' "$INPUT" | jq -r '.page // empty') +PER_PAGE=$(printf '%s' "$INPUT" | jq -r '.per_page // empty') + +if [ -z "$REPO" ]; then + echo '{"error": "repo is required"}' >&2 + exit 1 +fi + +# --- Normalize repo format --- +if [[ "$REPO" == git@github.com:* ]]; then + REPO="${REPO#git@github.com:}" + REPO="${REPO%.git}" +fi +if [[ "$REPO" == https://github.com/* ]]; then + REPO="${REPO#https://github.com/}" + REPO="${REPO%.git}" +fi + +# --- Defaults and clamping --- +SORT="${SORT:-newest}" +PAGE="${PAGE:-1}" +PER_PAGE="${PER_PAGE:-30}" + +if [ "$PAGE" -lt 1 ] 2>/dev/null; then PAGE=1; fi +if [ "$PER_PAGE" -lt 1 ] 2>/dev/null; then PER_PAGE=1; fi +if [ "$PER_PAGE" -gt 100 ] 2>/dev/null; then PER_PAGE=100; fi + +OWNER="${REPO%%/*}" +REPO_NAME="${REPO##*/}" + +# --- Fetch forks --- +RESPONSE=$(gh api "repos/${OWNER}/${REPO_NAME}/forks" \ + --method GET \ + -f sort="$SORT" \ + -f page="$PAGE" \ + -f per_page="$PER_PAGE" 2>&1) || { + echo "{\"error\": \"GitHub API call failed: $RESPONSE\"}" >&2 + exit 1 +} + +# --- Parse and output --- +COUNT=$(printf '%s' "$RESPONSE" | jq 'length') +HAS_NEXT="false" +if [ "$COUNT" -eq "$PER_PAGE" ]; then + HAS_NEXT="true" +fi + +printf '%s' "$RESPONSE" | jq --arg repo "${OWNER}/${REPO_NAME}" \ + --argjson page "$PAGE" \ + --argjson per_page "$PER_PAGE" \ + --argjson count "$COUNT" \ + --argjson has_next "$HAS_NEXT" \ + '{ + repo: $repo, + forks: [.[] | { + full_name: .full_name, + owner: .owner.login, + created_at: .created_at, + updated_at: .updated_at, + stargazers_count: .stargazers_count, + url: .html_url + }], + pagination: { + page: $page, + per_page: $per_page, + count: $count, + has_next_page: $has_next + } + }' diff --git a/forge-skills/local/embedded/github/scripts/github-list-prs.sh b/forge-skills/local/embedded/github/scripts/github-list-prs.sh new file mode 100755 index 0000000..8be675c --- /dev/null +++ b/forge-skills/local/embedded/github/scripts/github-list-prs.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# github-list-prs.sh — List pull requests for a GitHub repository. +# Usage: ./github-list-prs.sh '{"repo": "owner/repo", "state": "open", "page": 1, "per_page": 30}' +# +# Requires: gh, jq +set -euo pipefail + +# --- Read input --- +INPUT="${1:-}" +if [ -z "$INPUT" ]; then + echo '{"error": "usage: github-list-prs.sh {\"repo\": \"...\", \"state\": \"open\", \"page\": 1, \"per_page\": 30}"}' >&2 + exit 1 +fi +if ! printf '%s' "$INPUT" | jq empty 2>/dev/null; then + echo '{"error": "invalid JSON input"}' >&2 + exit 1 +fi + +REPO=$(printf '%s' "$INPUT" | jq -r '.repo // empty') +STATE=$(printf '%s' "$INPUT" | jq -r '.state // empty') +PAGE=$(printf '%s' "$INPUT" | jq -r '.page // empty') +PER_PAGE=$(printf '%s' "$INPUT" | jq -r '.per_page // empty') + +if [ -z "$REPO" ]; then + echo '{"error": "repo is required"}' >&2 + exit 1 +fi + +# --- Normalize repo format --- +if [[ "$REPO" == git@github.com:* ]]; then + REPO="${REPO#git@github.com:}" + REPO="${REPO%.git}" +fi +if [[ "$REPO" == https://github.com/* ]]; then + REPO="${REPO#https://github.com/}" + REPO="${REPO%.git}" +fi + +# --- Defaults and clamping --- +STATE="${STATE:-open}" +PAGE="${PAGE:-1}" +PER_PAGE="${PER_PAGE:-30}" + +if [ "$PAGE" -lt 1 ] 2>/dev/null; then PAGE=1; fi +if [ "$PER_PAGE" -lt 1 ] 2>/dev/null; then PER_PAGE=1; fi +if [ "$PER_PAGE" -gt 100 ] 2>/dev/null; then PER_PAGE=100; fi + +OWNER="${REPO%%/*}" +REPO_NAME="${REPO##*/}" + +# --- Fetch PRs --- +RESPONSE=$(gh api "repos/${OWNER}/${REPO_NAME}/pulls" \ + --method GET \ + -f state="$STATE" \ + -f page="$PAGE" \ + -f per_page="$PER_PAGE" 2>&1) || { + echo "{\"error\": \"GitHub API call failed: $RESPONSE\"}" >&2 + exit 1 +} + +# --- Parse and output --- +COUNT=$(printf '%s' "$RESPONSE" | jq 'length') +HAS_NEXT="false" +if [ "$COUNT" -eq "$PER_PAGE" ]; then + HAS_NEXT="true" +fi + +printf '%s' "$RESPONSE" | jq --arg repo "${OWNER}/${REPO_NAME}" \ + --arg state "$STATE" \ + --argjson page "$PAGE" \ + --argjson per_page "$PER_PAGE" \ + --argjson count "$COUNT" \ + --argjson has_next "$HAS_NEXT" \ + '{ + repo: $repo, + state: $state, + pull_requests: [.[] | { + number: .number, + title: .title, + state: .state, + user: .user.login, + created_at: .created_at, + updated_at: .updated_at, + head_ref: .head.ref, + base_ref: .base.ref, + url: .html_url + }], + pagination: { + page: $page, + per_page: $per_page, + count: $count, + has_next_page: $has_next + } + }' diff --git a/forge-skills/local/embedded/github/scripts/github-list-stargazers.sh b/forge-skills/local/embedded/github/scripts/github-list-stargazers.sh new file mode 100755 index 0000000..9fec38b --- /dev/null +++ b/forge-skills/local/embedded/github/scripts/github-list-stargazers.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# github-list-stargazers.sh — List stargazers for a GitHub repository. +# Usage: ./github-list-stargazers.sh '{"repo": "owner/repo", "page": 1, "per_page": 30}' +# +# Requires: gh, jq +set -euo pipefail + +# --- Read input --- +INPUT="${1:-}" +if [ -z "$INPUT" ]; then + echo '{"error": "usage: github-list-stargazers.sh {\"repo\": \"...\", \"page\": 1, \"per_page\": 30}"}' >&2 + exit 1 +fi +if ! printf '%s' "$INPUT" | jq empty 2>/dev/null; then + echo '{"error": "invalid JSON input"}' >&2 + exit 1 +fi + +REPO=$(printf '%s' "$INPUT" | jq -r '.repo // empty') +PAGE=$(printf '%s' "$INPUT" | jq -r '.page // empty') +PER_PAGE=$(printf '%s' "$INPUT" | jq -r '.per_page // empty') + +if [ -z "$REPO" ]; then + echo '{"error": "repo is required"}' >&2 + exit 1 +fi + +# --- Normalize repo format --- +if [[ "$REPO" == git@github.com:* ]]; then + REPO="${REPO#git@github.com:}" + REPO="${REPO%.git}" +fi +if [[ "$REPO" == https://github.com/* ]]; then + REPO="${REPO#https://github.com/}" + REPO="${REPO%.git}" +fi + +# --- Defaults and clamping --- +PAGE="${PAGE:-1}" +PER_PAGE="${PER_PAGE:-30}" + +if [ "$PAGE" -lt 1 ] 2>/dev/null; then PAGE=1; fi +if [ "$PER_PAGE" -lt 1 ] 2>/dev/null; then PER_PAGE=1; fi +if [ "$PER_PAGE" -gt 100 ] 2>/dev/null; then PER_PAGE=100; fi + +OWNER="${REPO%%/*}" +REPO_NAME="${REPO##*/}" + +# --- Fetch stargazers --- +RESPONSE=$(gh api "repos/${OWNER}/${REPO_NAME}/stargazers" \ + --method GET \ + -f page="$PAGE" \ + -f per_page="$PER_PAGE" 2>&1) || { + echo "{\"error\": \"GitHub API call failed: $RESPONSE\"}" >&2 + exit 1 +} + +# --- Parse and output --- +COUNT=$(printf '%s' "$RESPONSE" | jq 'length') +HAS_NEXT="false" +if [ "$COUNT" -eq "$PER_PAGE" ]; then + HAS_NEXT="true" +fi + +printf '%s' "$RESPONSE" | jq --arg repo "${OWNER}/${REPO_NAME}" \ + --argjson page "$PAGE" \ + --argjson per_page "$PER_PAGE" \ + --argjson count "$COUNT" \ + --argjson has_next "$HAS_NEXT" \ + '{ + repo: $repo, + stargazers: [.[] | { + login: .login, + url: .html_url + }], + pagination: { + page: $page, + per_page: $per_page, + count: $count, + has_next_page: $has_next + } + }' diff --git a/forge-skills/local/embedded/github/scripts/github-pr-author-profiles.sh b/forge-skills/local/embedded/github/scripts/github-pr-author-profiles.sh new file mode 100755 index 0000000..9aa901d --- /dev/null +++ b/forge-skills/local/embedded/github/scripts/github-pr-author-profiles.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# github-pr-author-profiles.sh — List PR authors and fetch their profiles (2-step compound tool). +# Usage: ./github-pr-author-profiles.sh '{"repo": "owner/repo", "state": "open", "page": 1, "per_page": 30}' +# +# Requires: gh, jq +set -euo pipefail + +# --- Read input --- +INPUT="${1:-}" +if [ -z "$INPUT" ]; then + echo '{"error": "usage: github-pr-author-profiles.sh {\"repo\": \"...\", \"state\": \"open\", \"page\": 1, \"per_page\": 30}"}' >&2 + exit 1 +fi +if ! printf '%s' "$INPUT" | jq empty 2>/dev/null; then + echo '{"error": "invalid JSON input"}' >&2 + exit 1 +fi + +REPO=$(printf '%s' "$INPUT" | jq -r '.repo // empty') +STATE=$(printf '%s' "$INPUT" | jq -r '.state // empty') +PAGE=$(printf '%s' "$INPUT" | jq -r '.page // empty') +PER_PAGE=$(printf '%s' "$INPUT" | jq -r '.per_page // empty') + +if [ -z "$REPO" ]; then + echo '{"error": "repo is required"}' >&2 + exit 1 +fi + +# --- Normalize repo format --- +if [[ "$REPO" == git@github.com:* ]]; then + REPO="${REPO#git@github.com:}" + REPO="${REPO%.git}" +fi +if [[ "$REPO" == https://github.com/* ]]; then + REPO="${REPO#https://github.com/}" + REPO="${REPO%.git}" +fi + +# --- Defaults and clamping --- +STATE="${STATE:-open}" +PAGE="${PAGE:-1}" +PER_PAGE="${PER_PAGE:-30}" + +if [ "$PAGE" -lt 1 ] 2>/dev/null; then PAGE=1; fi +if [ "$PER_PAGE" -lt 1 ] 2>/dev/null; then PER_PAGE=1; fi +if [ "$PER_PAGE" -gt 100 ] 2>/dev/null; then PER_PAGE=100; fi + +OWNER="${REPO%%/*}" +REPO_NAME="${REPO##*/}" + +# --- Step 1: Fetch PRs and extract unique authors with PR counts --- +PR_RESPONSE=$(gh api "repos/${OWNER}/${REPO_NAME}/pulls" \ + --method GET \ + -f state="$STATE" \ + -f page="$PAGE" \ + -f per_page="$PER_PAGE" 2>&1) || { + echo "{\"error\": \"GitHub API call failed: $PR_RESPONSE\"}" >&2 + exit 1 +} + +TOTAL_PRS=$(printf '%s' "$PR_RESPONSE" | jq 'length') +HAS_NEXT="false" +if [ "$TOTAL_PRS" -eq "$PER_PAGE" ]; then + HAS_NEXT="true" +fi + +# Extract unique authors with their PR counts +AUTHORS_WITH_COUNTS=$(printf '%s' "$PR_RESPONSE" | jq -r '[.[] | .user.login] | group_by(.) | map({login: .[0], pr_count: length}) | .[] | @json') +UNIQUE_AUTHORS=$(printf '%s' "$PR_RESPONSE" | jq '[.[] | .user.login] | unique | length') + +# --- Step 2: Fetch profile for each unique author --- +PROFILES="[]" + +while IFS= read -r author_json; do + [ -z "$author_json" ] && continue + LOGIN=$(printf '%s' "$author_json" | jq -r '.login') + PR_COUNT=$(printf '%s' "$author_json" | jq -r '.pr_count') + + PROFILE=$(gh api "users/${LOGIN}" 2>/dev/null) || { + # Gracefully skip failed profile fetches + PROFILES=$(printf '%s' "$PROFILES" | jq --arg login "$LOGIN" --argjson pr_count "$PR_COUNT" \ + '. + [{login: $login, error: "failed to fetch profile", pr_count: $pr_count}]') + continue + } + + PROFILES=$(printf '%s' "$PROFILE" | jq --argjson existing "$PROFILES" --argjson pr_count "$PR_COUNT" \ + '$existing + [{ + login: .login, + name: .name, + email: .email, + bio: .bio, + company: .company, + location: .location, + blog: .blog, + public_repos: .public_repos, + followers: .followers, + following: .following, + created_at: .created_at, + url: .html_url, + pr_count: $pr_count + }]') +done <<< "$AUTHORS_WITH_COUNTS" + +# --- Output --- +jq -n \ + --arg repo "${OWNER}/${REPO_NAME}" \ + --arg state "$STATE" \ + --argjson profiles "$PROFILES" \ + --argjson total_prs "$TOTAL_PRS" \ + --argjson unique_authors "$UNIQUE_AUTHORS" \ + --argjson page "$PAGE" \ + --argjson per_page "$PER_PAGE" \ + --argjson count "$TOTAL_PRS" \ + --argjson has_next "$HAS_NEXT" \ + '{ + repo: $repo, + state: $state, + profiles: $profiles, + total_prs_scanned: $total_prs, + unique_authors: $unique_authors, + pagination: { + page: $page, + per_page: $per_page, + count: $count, + has_next_page: $has_next + } + }' diff --git a/forge-skills/local/embedded/github/scripts/github-stargazer-profiles.sh b/forge-skills/local/embedded/github/scripts/github-stargazer-profiles.sh new file mode 100755 index 0000000..5d8fb61 --- /dev/null +++ b/forge-skills/local/embedded/github/scripts/github-stargazer-profiles.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# github-stargazer-profiles.sh — List stargazers and fetch their profiles (2-step compound tool). +# Usage: ./github-stargazer-profiles.sh '{"repo": "owner/repo", "page": 1, "per_page": 30}' +# +# Requires: gh, jq +set -euo pipefail + +# --- Read input --- +INPUT="${1:-}" +if [ -z "$INPUT" ]; then + echo '{"error": "usage: github-stargazer-profiles.sh {\"repo\": \"...\", \"page\": 1, \"per_page\": 30}"}' >&2 + exit 1 +fi +if ! printf '%s' "$INPUT" | jq empty 2>/dev/null; then + echo '{"error": "invalid JSON input"}' >&2 + exit 1 +fi + +REPO=$(printf '%s' "$INPUT" | jq -r '.repo // empty') +PAGE=$(printf '%s' "$INPUT" | jq -r '.page // empty') +PER_PAGE=$(printf '%s' "$INPUT" | jq -r '.per_page // empty') + +if [ -z "$REPO" ]; then + echo '{"error": "repo is required"}' >&2 + exit 1 +fi + +# --- Normalize repo format --- +if [[ "$REPO" == git@github.com:* ]]; then + REPO="${REPO#git@github.com:}" + REPO="${REPO%.git}" +fi +if [[ "$REPO" == https://github.com/* ]]; then + REPO="${REPO#https://github.com/}" + REPO="${REPO%.git}" +fi + +# --- Defaults and clamping --- +PAGE="${PAGE:-1}" +PER_PAGE="${PER_PAGE:-30}" + +if [ "$PAGE" -lt 1 ] 2>/dev/null; then PAGE=1; fi +if [ "$PER_PAGE" -lt 1 ] 2>/dev/null; then PER_PAGE=1; fi +if [ "$PER_PAGE" -gt 100 ] 2>/dev/null; then PER_PAGE=100; fi + +OWNER="${REPO%%/*}" +REPO_NAME="${REPO##*/}" + +# --- Step 1: Fetch stargazers --- +STAR_RESPONSE=$(gh api "repos/${OWNER}/${REPO_NAME}/stargazers" \ + --method GET \ + -f page="$PAGE" \ + -f per_page="$PER_PAGE" 2>&1) || { + echo "{\"error\": \"GitHub API call failed: $STAR_RESPONSE\"}" >&2 + exit 1 +} + +TOTAL_STARGAZERS=$(printf '%s' "$STAR_RESPONSE" | jq 'length') +HAS_NEXT="false" +if [ "$TOTAL_STARGAZERS" -eq "$PER_PAGE" ]; then + HAS_NEXT="true" +fi + +# Extract unique logins +LOGINS=$(printf '%s' "$STAR_RESPONSE" | jq -r '[.[] | .login] | unique | .[]') +UNIQUE_USERS=$(printf '%s' "$STAR_RESPONSE" | jq '[.[] | .login] | unique | length') + +# --- Step 2: Fetch profile for each unique stargazer --- +PROFILES="[]" + +while IFS= read -r LOGIN; do + [ -z "$LOGIN" ] && continue + + PROFILE=$(gh api "users/${LOGIN}" 2>/dev/null) || { + # Gracefully skip failed profile fetches + PROFILES=$(printf '%s' "$PROFILES" | jq --arg login "$LOGIN" \ + '. + [{login: $login, error: "failed to fetch profile"}]') + continue + } + + PROFILES=$(printf '%s' "$PROFILE" | jq --argjson existing "$PROFILES" \ + '$existing + [{ + login: .login, + name: .name, + email: .email, + bio: .bio, + company: .company, + location: .location, + blog: .blog, + public_repos: .public_repos, + followers: .followers, + following: .following, + created_at: .created_at, + url: .html_url + }]') +done <<< "$LOGINS" + +# --- Output --- +jq -n \ + --arg repo "${OWNER}/${REPO_NAME}" \ + --argjson profiles "$PROFILES" \ + --argjson total_stargazers "$TOTAL_STARGAZERS" \ + --argjson unique_users "$UNIQUE_USERS" \ + --argjson page "$PAGE" \ + --argjson per_page "$PER_PAGE" \ + --argjson count "$TOTAL_STARGAZERS" \ + --argjson has_next "$HAS_NEXT" \ + '{ + repo: $repo, + profiles: $profiles, + total_stargazers_scanned: $total_stargazers, + unique_users: $unique_users, + pagination: { + page: $page, + per_page: $per_page, + count: $count, + has_next_page: $has_next + } + }'