diff --git a/README.md b/README.md index 81496ad8..f1f51c9b 100644 --- a/README.md +++ b/README.md @@ -514,6 +514,10 @@ The generated workflow: After committing the workflow and setting the provider secret, comment `/kit ` on any issue or pull request to trigger Kit. +The runtime that reads the event context, enforces permissions, drives the agent, +and posts the response back is the +[`github-handler`](examples/extensions/github-handler) example extension. + | Flag | Description | | --- | --- | | `--model` | Provider/model to write into the workflow | diff --git a/examples/extensions/README.md b/examples/extensions/README.md index 40465572..e4ced429 100644 --- a/examples/extensions/README.md +++ b/examples/extensions/README.md @@ -83,6 +83,7 @@ kit install github.com/mark3labs/kit/examples/extensions --local | Extension | Description | Key API | |-----------|-------------|---------| | `kit-telegram/` | Telegram relay for remote monitoring & control | `RegisterCommand`, `OnAgentStart/End`, `SetStatus`, `SendMessage` | +| `github-handler/` | Run Kit as a GitHub collaborator inside Actions (`/kit` comments → reviews & PRs) | `OnSessionStart`, `OnAgentEnd`, `SendMessage`, `RegisterOption` | ### Themes @@ -146,6 +147,15 @@ Full-featured Telegram integration: - Status bar and widget updates - Config persistence with atomic writes +### github-handler/ +Run Kit as a GitHub collaborator inside GitHub Actions: +- Parses the triggering event from `GITHUB_EVENT_PATH` +- Permission gate on `author_association` (write/admin only) +- 👀 reaction lifecycle on the trigger comment +- Issue-thread / PR-diff context extraction via `gh` +- Drives the agent, posts the response, and opens a PR for any changes +- `KIT_GITHUB_DRY_RUN` mode for safe, deterministic testing + ## Multi-File Extension Example The `kit-kit-agents/` directory demonstrates the multi-file pattern: diff --git a/examples/extensions/github-handler/README.md b/examples/extensions/github-handler/README.md new file mode 100644 index 00000000..2b76bf63 --- /dev/null +++ b/examples/extensions/github-handler/README.md @@ -0,0 +1,75 @@ +# GitHub Handler Extension + +The GitHub handler is the runtime half of Kit's GitHub integration (issue +[#60](https://github.com/mark3labs/kit/issues/60), Phase 2b). It is designed to +run **inside a GitHub Actions runner**, driven by the workflow that +`kit github install` scaffolds. + +When a collaborator comments `/kit ` on an issue or pull request, the +workflow boots Kit headlessly with this extension loaded. The extension then: + +1. **Parses** the triggering event from `GITHUB_EVENT_PATH`. +2. **Enforces permissions** — only comments whose `author_association` is + `OWNER`, `MEMBER`, or `COLLABORATOR` are acted on. +3. **Reacts** with 👀 on the trigger comment so the human knows Kit is working. +4. **Gathers context** — the issue thread, or the pull request diff and + comments (via the `gh` CLI). +5. **Drives the agent** with the request plus context. +6. **Posts the response** back as a comment, and — if the agent left + uncommitted changes — pushes a branch as the `kit-agent[bot]` identity and + opens a pull request. +7. **Swaps the reaction** to 🚀 (or 😕 on error) when finished. + +Outside of GitHub Actions (i.e. when `GITHUB_ACTIONS != "true"`) the extension +is inert, so it is safe to keep loaded during normal local use. + +## Requirements + +- The [`gh` CLI](https://cli.github.com/) on `PATH`, authenticated via + `GITHUB_TOKEN` (GitHub Actions provides this automatically). +- `git` on `PATH` with push access for opening pull requests. +- A provider API key for the model Kit runs (e.g. `ANTHROPIC_API_KEY`). + +## Usage in a workflow + +`kit github install` writes `.github/workflows/kit.yml`. To wire in this +handler, load it when invoking Kit headlessly inside the action, for example: + +```bash +kit --quiet --no-session \ + -e path/to/github-handler/main.go \ + --model "$KIT_MODEL" +``` + +The extension reads the GitHub event itself, so no prompt argument is required — +it constructs the prompt from the comment and repository context and drives the +agent via the session lifecycle. + +## Environment variables + +| Variable | Purpose | +|----------------------|---------------------------------------------------------------| +| `GITHUB_ACTIONS` | Must be `true` for the handler to activate. | +| `GITHUB_EVENT_PATH` | Path to the JSON event payload (set by Actions). | +| `GITHUB_TOKEN` | Used by `gh`/`git` for API and push operations. | +| `KIT_GITHUB_DRY_RUN` | When set, log `gh`/`git` side effects instead of running them. | + +## Options + +| Option | Default | Description | +|-------------------|---------|------------------------------------------------------| +| `github.dry-run` | `false` | Log GitHub/git side effects instead of executing. | + +## Dry-run mode + +Set `KIT_GITHUB_DRY_RUN=1` (or the `github.dry-run` option) to exercise the +parsing, permission, and prompt-building logic without touching the network or +the working tree. Every `gh`/`git` mutation is printed instead of executed. +This is what the unit tests (`main_test.go`) use to stay deterministic. + +## Security + +- Triggers are gated to write/admin collaborators only. +- The workflow uses `persist-credentials: false` and least-privilege + `permissions`, mirroring established practice. +- Commits are attributed to a dedicated `kit-agent[bot]` identity. diff --git a/examples/extensions/github-handler/main.go b/examples/extensions/github-handler/main.go new file mode 100644 index 00000000..b03398e8 --- /dev/null +++ b/examples/extensions/github-handler/main.go @@ -0,0 +1,483 @@ +//go:build ignore + +// Package main implements the Kit GitHub handler extension. +// +// This is the Phase 2b "GitHub handler" piece of Kit's GitHub integration +// (issue #60). It is designed to run *inside a GitHub Actions runner*, driven +// by the workflow scaffolded by `kit github install`. When a collaborator +// comments `/kit ` on an issue or pull request, the workflow boots Kit +// headlessly with this extension loaded; the extension then: +// +// - parses the triggering GitHub event from GITHUB_EVENT_PATH, +// - enforces that the comment author has write/admin access +// (author_association in OWNER / MEMBER / COLLABORATOR), +// - reacts with 👀 on the trigger comment while it works, +// - gathers context (issue thread or PR diff) and drives the agent with it, +// - posts the agent's response back as a comment, and +// - if the agent left uncommitted changes, pushes a branch as the +// `kit-agent[bot]` identity and opens a pull request. +// +// Outside of GitHub Actions (i.e. when GITHUB_ACTIONS != "true") the extension +// is inert, so it is safe to keep loaded during normal local use. +// +// Set KIT_GITHUB_DRY_RUN=1 (or the `github.dry-run` option) to exercise the +// parsing / permission / prompt-building logic without shelling out to `gh` or +// `git` — every side effect is logged instead of executed. This is what the +// unit tests use. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "kit/ext" +) + +// commandToken is the mention that triggers Kit from a comment, mirroring the +// `if:` guard in the generated workflow (.github/workflows/kit.yml). +const commandToken = "/kit" + +// subprocessTimeout bounds each git/gh invocation so a stalled network call or +// an unexpected auth prompt cannot hang the Actions job indefinitely. +const subprocessTimeout = 30 * time.Second + +// botName / botEmail are the dedicated identity commits are attributed to, so +// Kit's changes are clearly distinguishable from human authors in history. +const ( + botName = "kit-agent[bot]" + botEmail = "kit-agent[bot]@users.noreply.github.com" +) + +// writeAssociations are the GitHub author_association values that imply +// write/admin access. Only these may trigger the handler. +var writeAssociations = map[string]bool{ + "OWNER": true, + "MEMBER": true, + "COLLABORATOR": true, +} + +// ghUser is a GitHub user as embedded in event payloads. +type ghUser struct { + Login string `json:"login"` +} + +// ghComment is the triggering comment. +type ghComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + AuthorAssociation string `json:"author_association"` + User ghUser `json:"user"` +} + +// ghIssue is the issue (or PR, since GitHub models PRs as issues) the comment +// was posted on. PullRequest is non-nil when the issue is actually a PR. +type ghIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + PullRequest json.RawMessage `json:"pull_request"` +} + +// ghPull is the pull request for pull_request_review_comment events. +type ghPull struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` +} + +// ghRepo identifies the repository the event originated from. +type ghRepo struct { + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` +} + +// ghEvent is the subset of the GitHub Actions event payload the handler reads. +type ghEvent struct { + Action string `json:"action"` + Comment *ghComment `json:"comment"` + Issue *ghIssue `json:"issue"` + PullRequest *ghPull `json:"pull_request"` + Repository ghRepo `json:"repository"` +} + +// trigger captures everything the handler needs about a single invocation, +// normalised across issue_comment and pull_request_review_comment events. +type trigger struct { + repo string + defaultBranch string + number int // issue or PR number + isPR bool // true when the target is a pull request + commentID int64 // triggering comment id (for reactions) + commentKind string // "issues" or "pulls" — reaction API path segment + author string + association string + request string // the user's instruction (comment body minus the token) + title string + body string +} + +func Init(api ext.API) { + api.RegisterOption(ext.OptionDef{ + Name: "github.dry-run", + Description: "Log GitHub/git side effects instead of executing them", + Default: "false", + }) + + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + if !inGitHubActions() { + return + } + handleSessionStart(ctx) + }) + + api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) { + if !inGitHubActions() || activeTrigger == nil { + return + } + handleAgentEnd(e, ctx) + }) +} + +// activeTrigger holds the parsed trigger between OnSessionStart and OnAgentEnd. +// Yaegi supports package-level state captured by the handler closures. +var activeTrigger *trigger + +func inGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") == "true" +} + +// dryRun reports whether side effects should be logged instead of executed. +func dryRun(ctx ext.Context) bool { + if os.Getenv("KIT_GITHUB_DRY_RUN") != "" { + return true + } + return strings.EqualFold(ctx.GetOption("github.dry-run"), "true") +} + +// handleSessionStart parses the event, enforces permissions, reacts on the +// trigger comment, builds the prompt, and drives the agent. +func handleSessionStart(ctx ext.Context) { + event, err := loadEvent() + if err != nil { + ctx.PrintError("kit-github: " + err.Error()) + ctx.Exit() + return + } + + tr, err := buildTrigger(event) + if err != nil { + // Not an actionable trigger (e.g. a comment without /kit). Stay quiet + // and let the run finish; the workflow `if:` normally prevents this. + ctx.PrintInfo("kit-github: " + err.Error()) + ctx.Exit() + return + } + + if !writeAssociations[strings.ToUpper(tr.association)] { + ctx.PrintError(fmt.Sprintf( + "kit-github: ignoring /kit from @%s — author_association %q lacks write access", + tr.author, tr.association)) + ctx.Exit() + return + } + + activeTrigger = tr + + // React with 👀 so the human sees Kit picked up the request. + addReaction(ctx, tr, "eyes") + + context := gatherContext(ctx, tr) + prompt := buildPrompt(tr, context) + ctx.SendMessage(prompt) +} + +// loadEvent reads and decodes the GitHub Actions event payload. +func loadEvent() (*ghEvent, error) { + path := os.Getenv("GITHUB_EVENT_PATH") + if path == "" { + return nil, fmt.Errorf("GITHUB_EVENT_PATH is not set") + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading event payload: %w", err) + } + var event ghEvent + if err := json.Unmarshal(data, &event); err != nil { + return nil, fmt.Errorf("parsing event payload: %w", err) + } + return &event, nil +} + +// buildTrigger normalises an event into a trigger, or returns an error when the +// event is not an actionable `/kit` comment. +func buildTrigger(event *ghEvent) (*trigger, error) { + if event.Comment == nil { + return nil, fmt.Errorf("event has no comment; nothing to do") + } + + request, ok := extractRequest(event.Comment.Body) + if !ok { + return nil, fmt.Errorf("comment does not contain the %q command", commandToken) + } + + tr := &trigger{ + repo: event.Repository.FullName, + defaultBranch: event.Repository.DefaultBranch, + commentID: event.Comment.ID, + author: event.Comment.User.Login, + association: event.Comment.AuthorAssociation, + request: request, + } + if tr.defaultBranch == "" { + tr.defaultBranch = "main" + } + + switch { + case event.Issue != nil: + tr.number = event.Issue.Number + tr.title = event.Issue.Title + tr.body = event.Issue.Body + tr.isPR = len(event.Issue.PullRequest) > 0 + // issue_comment fires for both issues and PRs; reactions for PR + // comments posted on the conversation tab still use the issues path. + tr.commentKind = "issues" + case event.PullRequest != nil: + tr.number = event.PullRequest.Number + tr.title = event.PullRequest.Title + tr.body = event.PullRequest.Body + tr.isPR = true + // pull_request_review_comment reactions use the pulls path. + tr.commentKind = "pulls" + default: + return nil, fmt.Errorf("event has no issue or pull_request target") + } + + if tr.repo == "" { + return nil, fmt.Errorf("event is missing repository.full_name") + } + return tr, nil +} + +// extractRequest pulls the instruction text out of a comment body that mentions +// the command token. It only recognizes the token at the start of a line +// (mirroring the workflow guard) or at the very end, so incidental mid-sentence +// mentions like "please review /kit behavior" do not trigger the handler. It +// returns the remainder of the matching line as the request. +func extractRequest(body string) (string, bool) { + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + var rest string + switch { + case trimmed == commandToken: + return "", true + case strings.HasPrefix(trimmed, commandToken+" "): + rest = trimmed[len(commandToken):] + case strings.HasSuffix(trimmed, " "+commandToken): + return "", true + default: + continue + } + return strings.TrimSpace(rest), true + } + return "", false +} + +// gatherContext assembles the issue thread or PR diff to give the agent. It +// always includes the title/body from the event payload, and — outside dry-run, +// when `gh` is available — enriches with the full comment thread and PR diff. +func gatherContext(ctx ext.Context, tr *trigger) string { + var b strings.Builder + target := "Issue" + if tr.isPR { + target = "Pull request" + } + fmt.Fprintf(&b, "%s #%d: %s\n", target, tr.number, tr.title) + if strings.TrimSpace(tr.body) != "" { + fmt.Fprintf(&b, "\n%s\n", strings.TrimSpace(tr.body)) + } + + if dryRun(ctx) || !commandExists("gh") { + return b.String() + } + + if tr.isPR { + if diff := ghOutput(ctx, "pr", "diff", fmt.Sprint(tr.number), "--repo", tr.repo); diff != "" { + fmt.Fprintf(&b, "\n## Diff\n```diff\n%s\n```\n", strings.TrimSpace(diff)) + } + if comments := ghOutput(ctx, "pr", "view", fmt.Sprint(tr.number), "--repo", tr.repo, "--json", "comments", "--jq", ".comments[] | \"@\\(.author.login): \\(.body)\""); comments != "" { + fmt.Fprintf(&b, "\n## Comments\n%s\n", strings.TrimSpace(comments)) + } + } else { + if comments := ghOutput(ctx, "issue", "view", fmt.Sprint(tr.number), "--repo", tr.repo, "--json", "comments", "--jq", ".comments[] | \"@\\(.author.login): \\(.body)\""); comments != "" { + fmt.Fprintf(&b, "\n## Comments\n%s\n", strings.TrimSpace(comments)) + } + } + return b.String() +} + +// buildPrompt constructs the instruction sent to the agent. +func buildPrompt(tr *trigger, context string) string { + target := "issue" + if tr.isPR { + target = "pull request" + } + request := tr.request + if request == "" { + request = "(no explicit instruction — review the " + target + " and respond helpfully)" + } + + var b strings.Builder + fmt.Fprintf(&b, "You are Kit, operating as an automated collaborator on the GitHub repository %s.\n\n", tr.repo) + fmt.Fprintf(&b, "@%s (access: %s) triggered you on %s #%d with this request:\n\n", tr.author, tr.association, target, tr.number) + fmt.Fprintf(&b, "%s\n\n", request) + fmt.Fprintf(&b, "## Context\n%s\n\n", strings.TrimSpace(context)) + b.WriteString("Carry out the request. If you modify files, they will be committed to a new ") + b.WriteString("branch and a pull request will be opened automatically, so you do not need to ") + b.WriteString("commit or push yourself. Finish with a concise summary of what you did.") + return b.String() +} + +// handleAgentEnd posts the agent's response, opens a PR for any uncommitted +// changes, and swaps the reaction to signal completion. +func handleAgentEnd(e ext.AgentEndEvent, ctx ext.Context) { + tr := activeTrigger + response := strings.TrimSpace(e.Response) + if response == "" { + response = "Kit finished without a textual response." + } + + if e.StopReason == "error" { + comment := "⚠️ Kit hit an error while processing this request:\n\n" + response + postComment(ctx, tr, comment) + addReaction(ctx, tr, "confused") + ctx.Exit() + return + } + + prURL := "" + if hasUncommittedChanges(ctx) { + prURL = openPullRequest(ctx, tr, response) + } + + comment := response + if prURL != "" { + comment += "\n\n---\nOpened a pull request with the changes: " + prURL + } + postComment(ctx, tr, comment) + addReaction(ctx, tr, "rocket") + ctx.Exit() +} + +// hasUncommittedChanges reports whether the working tree has changes the agent +// produced. In dry-run it reports the value of KIT_GITHUB_FAKE_DIRTY so tests +// stay deterministic. +func hasUncommittedChanges(ctx ext.Context) bool { + if dryRun(ctx) { + return os.Getenv("KIT_GITHUB_FAKE_DIRTY") != "" + } + out := gitOutput(ctx, "status", "--porcelain") + return strings.TrimSpace(out) != "" +} + +// openPullRequest commits the working tree as kit-agent[bot], pushes a branch, +// and opens a PR. It returns the PR URL, or "" on failure / dry-run. +func openPullRequest(ctx ext.Context, tr *trigger, summary string) string { + branch := fmt.Sprintf("kit/issue-%d-%d", tr.number, time.Now().Unix()) + + runGit(ctx, "checkout", "-b", branch) + runGit(ctx, "add", "-A") + runGit(ctx, "-c", "user.name="+botName, "-c", "user.email="+botEmail, + "commit", "-m", fmt.Sprintf("kit: address #%d", tr.number)) + runGit(ctx, "push", "origin", "HEAD:"+branch) + + title := fmt.Sprintf("kit: changes for #%d", tr.number) + body := fmt.Sprintf("Automated changes from Kit in response to #%d.\n\n%s", tr.number, summary) + if dryRun(ctx) { + ctx.Print(fmt.Sprintf("[dry-run] gh pr create --head %s --base %s", branch, tr.defaultBranch)) + return "" + } + url := ghOutput(ctx, "pr", "create", "--repo", tr.repo, + "--head", branch, "--base", tr.defaultBranch, + "--title", title, "--body", body) + return strings.TrimSpace(url) +} + +// addReaction adds an emoji reaction to the trigger comment. +func addReaction(ctx ext.Context, tr *trigger, content string) { + path := fmt.Sprintf("/repos/%s/%s/comments/%d/reactions", tr.repo, tr.commentKind, tr.commentID) + if dryRun(ctx) || !commandExists("gh") { + ctx.Print(fmt.Sprintf("[dry-run] react %q on %s", content, path)) + return + } + runCmd(ctx, "gh", "api", "-X", "POST", path, "-f", "content="+content) +} + +// postComment posts a comment back on the triggering issue or pull request. +func postComment(ctx ext.Context, tr *trigger, body string) { + sub := "issue" + if tr.isPR { + sub = "pr" + } + if dryRun(ctx) || !commandExists("gh") { + ctx.Print(fmt.Sprintf("[dry-run] gh %s comment %d --body <%d chars>", sub, tr.number, len(body))) + return + } + runCmd(ctx, "gh", sub, "comment", fmt.Sprint(tr.number), "--repo", tr.repo, "--body", body) +} + +// --- thin subprocess helpers ------------------------------------------------- + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// runGit runs a mutating git command, logging instead of executing in dry-run. +func runGit(ctx ext.Context, args ...string) { + if dryRun(ctx) { + ctx.Print("[dry-run] git " + strings.Join(args, " ")) + return + } + runCmd(ctx, "git", args...) +} + +// gitOutput runs a read-only git command and returns its stdout. +func gitOutput(ctx ext.Context, args ...string) string { + cmdCtx, cancel := context.WithTimeout(context.Background(), subprocessTimeout) + defer cancel() + cmd := exec.CommandContext(cmdCtx, "git", args...) + out, err := cmd.Output() + if err != nil { + ctx.PrintError(fmt.Sprintf("kit-github: git %s failed: %v", strings.Join(args, " "), err)) + return "" + } + return string(out) +} + +// ghOutput runs a gh command and returns its stdout. +func ghOutput(ctx ext.Context, args ...string) string { + cmdCtx, cancel := context.WithTimeout(context.Background(), subprocessTimeout) + defer cancel() + cmd := exec.CommandContext(cmdCtx, "gh", args...) + out, err := cmd.Output() + if err != nil { + ctx.PrintError(fmt.Sprintf("kit-github: gh %s failed: %v", strings.Join(args, " "), err)) + return "" + } + return string(out) +} + +// runCmd runs a command for its side effects, surfacing failures via PrintError. +func runCmd(ctx ext.Context, name string, args ...string) { + cmdCtx, cancel := context.WithTimeout(context.Background(), subprocessTimeout) + defer cancel() + cmd := exec.CommandContext(cmdCtx, name, args...) + if out, err := cmd.CombinedOutput(); err != nil { + ctx.PrintError(fmt.Sprintf("kit-github: %s failed: %v\n%s", name, err, strings.TrimSpace(string(out)))) + } +} diff --git a/examples/extensions/github-handler/main_test.go b/examples/extensions/github-handler/main_test.go new file mode 100644 index 00000000..338855ac --- /dev/null +++ b/examples/extensions/github-handler/main_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mark3labs/kit/internal/extensions" + "github.com/mark3labs/kit/pkg/extensions/test" +) + +// writeEvent writes a GitHub event payload to a temp file and points +// GITHUB_EVENT_PATH at it. It also forces the extension into dry-run and +// pretends we are running inside GitHub Actions. +func writeEvent(t *testing.T, payload string) { + t.Helper() + path := filepath.Join(t.TempDir(), "event.json") + if err := os.WriteFile(path, []byte(payload), 0o644); err != nil { + t.Fatalf("write event: %v", err) + } + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("KIT_GITHUB_DRY_RUN", "1") + t.Setenv("GITHUB_EVENT_PATH", path) +} + +const issueCommentEvent = `{ + "action": "created", + "comment": { + "id": 555, + "body": "/kit fix the broken parser", + "author_association": "OWNER", + "user": {"login": "alice"} + }, + "issue": {"number": 42, "title": "Parser crashes on empty input", "body": "It panics."}, + "repository": {"full_name": "acme/widgets", "default_branch": "main"} +}` + +func TestGitHubHandler_RegistersHandlers(t *testing.T) { + harness := test.New(t) + ext := harness.LoadFile("main.go") + if ext == nil { + t.Fatal("extension should not be nil") + } + test.AssertHasHandlers(t, harness, extensions.SessionStart) + test.AssertHasHandlers(t, harness, extensions.AgentEnd) +} + +func TestGitHubHandler_InertOutsideActions(t *testing.T) { + // No GITHUB_ACTIONS env → the handler must do nothing. + t.Setenv("GITHUB_ACTIONS", "") + harness := test.New(t) + harness.LoadFile("main.go") + + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit: %v", err) + } + if msgs := harness.Context().Messages; len(msgs) != 0 { + t.Errorf("expected no messages outside Actions, got %v", msgs) + } +} + +func TestGitHubHandler_AuthorizedIssueComment(t *testing.T) { + writeEvent(t, issueCommentEvent) + + harness := test.New(t) + harness.LoadFile("main.go") + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit: %v", err) + } + + msgs := harness.Context().Messages + if len(msgs) != 1 { + t.Fatalf("expected exactly one driven prompt, got %d: %v", len(msgs), msgs) + } + prompt := msgs[0] + for _, want := range []string{ + "fix the broken parser", // the request + "acme/widgets", // the repo + "issue #42", // the target + "@alice", // the author + "Parser crashes on empty input", // context: title + "It panics.", // context: body + } { + if !strings.Contains(prompt, want) { + t.Errorf("prompt missing %q\n---\n%s", want, prompt) + } + } +} + +func TestGitHubHandler_UnauthorizedAssociation(t *testing.T) { + writeEvent(t, strings.Replace(issueCommentEvent, `"OWNER"`, `"NONE"`, 1)) + + harness := test.New(t) + harness.LoadFile("main.go") + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit: %v", err) + } + + if msgs := harness.Context().Messages; len(msgs) != 0 { + t.Fatalf("unauthorized author must not drive the agent, got %v", msgs) + } + if errs := harness.Context().GetPrintErrors(); len(errs) == 0 || + !strings.Contains(strings.Join(errs, "\n"), "lacks write access") { + t.Errorf("expected a write-access error, got %v", errs) + } +} + +func TestGitHubHandler_CommentWithoutToken(t *testing.T) { + writeEvent(t, strings.Replace(issueCommentEvent, + `"/kit fix the broken parser"`, `"just a normal comment"`, 1)) + + harness := test.New(t) + harness.LoadFile("main.go") + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit: %v", err) + } + if msgs := harness.Context().Messages; len(msgs) != 0 { + t.Fatalf("non-/kit comment must not drive the agent, got %v", msgs) + } +} + +func TestGitHubHandler_MidSentenceMentionIgnored(t *testing.T) { + // An incidental mid-sentence mention of the token must not trigger Kit. + writeEvent(t, strings.Replace(issueCommentEvent, + `"/kit fix the broken parser"`, `"please review /kit behavior in the docs"`, 1)) + + harness := test.New(t) + harness.LoadFile("main.go") + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit: %v", err) + } + if msgs := harness.Context().Messages; len(msgs) != 0 { + t.Fatalf("mid-sentence /kit mention must not drive the agent, got %v", msgs) + } +} + +func TestGitHubHandler_PullRequestReviewComment(t *testing.T) { + writeEvent(t, `{ + "action": "created", + "comment": { + "id": 999, + "body": "/kit review this change", + "author_association": "COLLABORATOR", + "user": {"login": "bob"} + }, + "pull_request": {"number": 7, "title": "Add caching", "body": "Speeds things up."}, + "repository": {"full_name": "acme/widgets", "default_branch": "main"} +}`) + + harness := test.New(t) + harness.LoadFile("main.go") + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit: %v", err) + } + msgs := harness.Context().Messages + if len(msgs) != 1 { + t.Fatalf("expected one driven prompt, got %v", msgs) + } + if !strings.Contains(msgs[0], "pull request #7") { + t.Errorf("expected PR target in prompt:\n%s", msgs[0]) + } +} + +func TestGitHubHandler_AgentEndPostsComment(t *testing.T) { + writeEvent(t, issueCommentEvent) + + harness := test.New(t) + harness.LoadFile("main.go") + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit session start: %v", err) + } + if _, err := harness.Emit(extensions.AgentEndEvent{ + Response: "Fixed the parser by guarding empty input.", + StopReason: "completed", + }); err != nil { + t.Fatalf("emit agent end: %v", err) + } + + prints := strings.Join(harness.Context().GetPrints(), "\n") + if !strings.Contains(prints, "gh issue comment 42") { + t.Errorf("expected a dry-run comment post, got prints:\n%s", prints) + } +} + +func TestGitHubHandler_AgentEndOpensPRWhenDirty(t *testing.T) { + writeEvent(t, issueCommentEvent) + t.Setenv("KIT_GITHUB_FAKE_DIRTY", "1") + + harness := test.New(t) + harness.LoadFile("main.go") + if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil { + t.Fatalf("emit session start: %v", err) + } + if _, err := harness.Emit(extensions.AgentEndEvent{ + Response: "Made changes.", + StopReason: "completed", + }); err != nil { + t.Fatalf("emit agent end: %v", err) + } + + prints := strings.Join(harness.Context().GetPrints(), "\n") + if !strings.Contains(prints, "gh pr create") { + t.Errorf("expected a dry-run PR creation, got prints:\n%s", prints) + } + if !strings.Contains(prints, "git checkout -b kit/issue-42-") { + t.Errorf("expected a dry-run branch checkout, got prints:\n%s", prints) + } +} diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index dcec3777..f900cdf3 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -372,8 +372,12 @@ func loadSingleExtension(path string) (*LoadedExtension, error) { Handlers: make(map[EventType][]HandlerFunc), } - // Create a fresh interpreter. - i := interp.New(interp.Options{}) + // Create a fresh interpreter. Yaegi runs extensions in restricted mode, + // where os.Getenv/os.LookupEnv/os.Environ read from a virtualized + // environment rather than the real one. Seed it with the process + // environment so extensions can read variables (e.g. CI-provided ones + // like GITHUB_EVENT_PATH) without being able to mutate the host's env. + i := interp.New(interp.Options{Env: os.Environ()}) // Expose the Go stdlib. The base set covers most packages; the // unrestricted set adds os/exec so extensions can spawn processes. diff --git a/pkg/extensions/test/harness.go b/pkg/extensions/test/harness.go index 8f17939e..cfd06287 100644 --- a/pkg/extensions/test/harness.go +++ b/pkg/extensions/test/harness.go @@ -91,8 +91,10 @@ func (h *Harness) LoadString(src string, path string) *extensions.LoadedExtensio func (h *Harness) loadSource(src string, path string) *extensions.LoadedExtension { h.t.Helper() - // Create a fresh interpreter - i := interp.New(interp.Options{}) + // Create a fresh interpreter. Seed the virtualized environment with the + // process environment so extensions can read env vars via os.Getenv, + // mirroring the production loader (see internal/extensions/loader.go). + i := interp.New(interp.Options{Env: os.Environ()}) // Expose Go stdlib if err := i.Use(stdlib.Symbols); err != nil { diff --git a/www/pages/cli/commands.md b/www/pages/cli/commands.md index a48fc869..ff056b7f 100644 --- a/www/pages/cli/commands.md +++ b/www/pages/cli/commands.md @@ -99,6 +99,8 @@ The generated workflow: After committing the workflow and setting the provider secret, comment `/kit ` on any issue or pull request to trigger Kit. +The runtime that reads the event context, enforces permissions, drives the agent, and posts the response back is the [`github-handler`](/extensions/examples) example extension. + | Flag | Description | |------|-------------| | `--model` | Provider/model to write into the workflow | diff --git a/www/pages/extensions/examples.md b/www/pages/extensions/examples.md index 5cb2cef4..a5e71422 100644 --- a/www/pages/extensions/examples.md +++ b/www/pages/extensions/examples.md @@ -90,6 +90,7 @@ These examples demonstrate the new bridged SDK APIs that give extensions access |-----------|-------------| | [`kit-kit-agents/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/kit-kit-agents) | Multi-agent orchestration example | | [`kit-telegram/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/kit-telegram) | Telegram bot integration | +| [`github-handler/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/github-handler) | Run Kit as a GitHub collaborator inside Actions — parses the event, gates on `author_association`, drives the agent, posts comments, and opens PRs | | [`status-tools/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/status-tools) | Status bar tool examples | ## Project-local example diff --git a/www/pages/extensions/loading.md b/www/pages/extensions/loading.md index 9dc1fefd..77508d8d 100644 --- a/www/pages/extensions/loading.md +++ b/www/pages/extensions/loading.md @@ -117,3 +117,34 @@ func Init(api ext.API) { }) } ``` + +### Standard library access + +Extensions can import the full Go standard library, plus `os/exec` for spawning +subprocesses. Environment variables are also readable: `os.Getenv`, +`os.LookupEnv`, and `os.Environ` return Kit's process environment, so extensions +can pick up CI-provided variables (for example `GITHUB_EVENT_PATH` or a provider +API key) and any vars the user exported before launching Kit. + +```go +package main + +import ( + "os" + "kit/ext" +) + +func Init(api ext.API) { + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + if eventPath := os.Getenv("GITHUB_EVENT_PATH"); eventPath != "" { + ctx.PrintInfo("Running in GitHub Actions: " + eventPath) + } + }) +} +``` + +Environment access is read-only from the host's perspective: the environment is +snapshotted when the extension loads, and calls to `os.Setenv` mutate only the +extension's sandboxed copy — they never change Kit's process environment or the +host. This keeps extensions from leaking state into Kit or other extensions +while still letting them read the configuration they need. diff --git a/www/pages/extensions/testing.md b/www/pages/extensions/testing.md index 2f06c64f..26f97606 100644 --- a/www/pages/extensions/testing.md +++ b/www/pages/extensions/testing.md @@ -392,6 +392,25 @@ harness2.LoadFile("ext2.go") // Events to one don't affect the other ``` +### Testing extensions that read environment variables + +The harness seeds the interpreter with the process environment, mirroring the +production loader, so an extension's `os.Getenv` / `os.LookupEnv` / `os.Environ` +calls work in tests. Set test-specific variables with `t.Setenv` **before** +loading the extension, since the environment is snapshotted at load time: + +```go +func TestReadsEnv(t *testing.T) { + t.Setenv("MY_API_KEY", "test-value") + + harness := test.New(t) + harness.LoadFile("my-ext.go") // snapshots the env, incl. MY_API_KEY + + harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}) + // assert on behavior that depends on MY_API_KEY +} +``` + ### Running Tests Run all tests in your extension directory: