mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-16 12:36:15 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08c3d0fe3a | |||
| 16662ca208 |
@@ -39,6 +39,36 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Keep floating major/minor tags (e.g. v1, v1.2) pointing at the latest
|
||||
# release so the composite action can be referenced as `mark3labs/kit@v1`.
|
||||
action-tags:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
if: ${{ github.event_name == 'push' && needs.goreleaser.result == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update floating major/minor tags
|
||||
env:
|
||||
FULL_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# FULL_TAG looks like v1.2.3 — derive v1 and v1.2.
|
||||
VER="${FULL_TAG#v}"
|
||||
MAJOR="v${VER%%.*}"
|
||||
MINOR="v${VER%.*}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
for t in "$MAJOR" "$MINOR"; do
|
||||
echo "Pointing $t at $FULL_TAG"
|
||||
git tag -f "$t" "$FULL_TAG"
|
||||
git push -f origin "refs/tags/$t"
|
||||
done
|
||||
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
|
||||
@@ -514,6 +514,13 @@ The generated workflow:
|
||||
After committing the workflow and setting the provider secret, comment
|
||||
`/kit <your request>` on any issue or pull request to trigger Kit.
|
||||
|
||||
The generated workflow uses the bundled [`mark3labs/kit`](action.yml) composite
|
||||
action, which installs the Kit binary and runs `kit github run`. That command
|
||||
reads the triggering event, enforces permissions, reacts with an emoji, runs the
|
||||
agent against the issue thread or pull request, posts the response as a comment,
|
||||
and — if the agent changed files — pushes a `kit-agent[bot]` branch and opens a
|
||||
pull request.
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `--model` | Provider/model to write into the workflow |
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
name: "Kit"
|
||||
description: "Run Kit as an automated collaborator/reviewer on GitHub issues and pull requests."
|
||||
author: "mark3labs"
|
||||
branding:
|
||||
icon: "git-merge"
|
||||
color: "purple"
|
||||
|
||||
inputs:
|
||||
model:
|
||||
description: "Provider/model Kit should use (e.g. anthropic/claude-sonnet-4-5-20250929). Defaults to Kit's built-in default."
|
||||
required: false
|
||||
default: ""
|
||||
version:
|
||||
description: "Kit version to install (e.g. v0.77.0). Defaults to the latest release."
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Kit
|
||||
shell: bash
|
||||
env:
|
||||
KIT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${KIT_VERSION:-latest}"
|
||||
if [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then
|
||||
VERSION="$(curl -fsSL https://api.github.com/repos/mark3labs/kit/releases/latest \
|
||||
| grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::could not determine Kit version to install" >&2
|
||||
exit 1
|
||||
fi
|
||||
VER="${VERSION#v}"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux) OS=linux ;;
|
||||
Darwin) OS=darwin ;;
|
||||
*) echo "::error::unsupported OS $(uname -s)" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ARCH=amd64 ;;
|
||||
aarch64|arm64) ARCH=arm64 ;;
|
||||
*) echo "::error::unsupported arch $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
URL="https://github.com/mark3labs/kit/releases/download/${VERSION}/kit_${VER}_${OS}_${ARCH}.tar.gz"
|
||||
echo "Installing Kit ${VERSION} from ${URL}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
curl -fsSL "$URL" | tar -xz -C "$TMP"
|
||||
mkdir -p "$HOME/.kit/bin"
|
||||
mv "$TMP/kit" "$HOME/.kit/bin/kit"
|
||||
chmod +x "$HOME/.kit/bin/kit"
|
||||
echo "$HOME/.kit/bin" >> "$GITHUB_PATH"
|
||||
rm -rf "$TMP"
|
||||
|
||||
- name: Verify Kit
|
||||
shell: bash
|
||||
run: kit --version
|
||||
|
||||
- name: Run Kit
|
||||
shell: bash
|
||||
env:
|
||||
MODEL: ${{ inputs.model }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARGS=()
|
||||
if [ -n "${MODEL:-}" ]; then
|
||||
ARGS+=(--model "$MODEL")
|
||||
fi
|
||||
kit github run ${ARGS[@]+"${ARGS[@]}"}
|
||||
+1
-1
@@ -173,7 +173,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: mark3labs/kit-action@v1
|
||||
- uses: mark3labs/kit@v0
|
||||
with:
|
||||
model: %s
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// agentTimeout bounds the headless agent run so a runaway turn cannot block the
|
||||
// job forever. GitHub Actions jobs have their own ceiling, but a tighter bound
|
||||
// keeps feedback fast and costs predictable.
|
||||
const agentTimeout = 20 * time.Minute
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
var (
|
||||
githubRunModel string
|
||||
githubRunDryRun bool
|
||||
)
|
||||
|
||||
// githubRunCmd is the runtime half of the GitHub integration. It is invoked by
|
||||
// the bundled composite action (action.yml) inside a GitHub Actions runner once
|
||||
// a collaborator comments '/kit <request>' on an issue or pull request. It reads
|
||||
// the triggering event, enforces permissions, runs the agent headlessly against
|
||||
// the comment/PR context, and responds by posting a comment and — when the agent
|
||||
// leaves changes — opening a pull request.
|
||||
var githubRunCmd = &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run Kit against the current GitHub Actions event (used by the kit action)",
|
||||
Long: `Run Kit against the current GitHub Actions event.
|
||||
|
||||
This command is normally invoked by the bundled composite action inside a
|
||||
GitHub Actions runner; you rarely run it by hand. It reads the triggering
|
||||
event from GITHUB_EVENT_PATH, verifies the commenter has write/admin access,
|
||||
reacts with an emoji while it works, runs the agent non-interactively against
|
||||
the issue thread or pull request, posts the response as a comment, and — if the
|
||||
agent modified files — pushes a kit-agent[bot] branch and opens a pull request.
|
||||
|
||||
Set --dry-run (or KIT_GITHUB_DRY_RUN=1) to log every git/gh side effect and
|
||||
skip the agent run instead of executing them.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runGitHubRun,
|
||||
}
|
||||
|
||||
func init() {
|
||||
githubRunCmd.Flags().StringVarP(&githubRunModel, "model", "m", "", "provider/model the agent should use (falls back to $MODEL, then a default)")
|
||||
githubRunCmd.Flags().BoolVar(&githubRunDryRun, "dry-run", false, "log git/gh side effects and skip the agent run instead of executing them")
|
||||
githubCmd.AddCommand(githubRunCmd)
|
||||
}
|
||||
|
||||
// --- GitHub event types ------------------------------------------------------
|
||||
|
||||
type ghUser struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type ghComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
AuthorAssociation string `json:"author_association"`
|
||||
User ghUser `json:"user"`
|
||||
}
|
||||
|
||||
type ghIssue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
PullRequest json.RawMessage `json:"pull_request"`
|
||||
}
|
||||
|
||||
type ghPull struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type ghRepo struct {
|
||||
FullName string `json:"full_name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
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 normalises a single invocation 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
|
||||
}
|
||||
|
||||
// runGitHubRun is the entry point wired to `kit github run`.
|
||||
func runGitHubRun(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if !inGitHubActions() && !githubDryRun() {
|
||||
return fmt.Errorf("kit github run is meant to run inside GitHub Actions (set GITHUB_ACTIONS=true or pass --dry-run)")
|
||||
}
|
||||
|
||||
event, err := loadGitHubEvent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr, err := buildTrigger(event)
|
||||
if err != nil {
|
||||
// Not an actionable trigger (the workflow `if:` normally prevents this).
|
||||
log.Info("github run: nothing to do", "reason", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !writeAssociations[strings.ToUpper(tr.association)] {
|
||||
log.Warn("github run: ignoring /kit from unauthorized author",
|
||||
"author", tr.author, "association", tr.association)
|
||||
return nil
|
||||
}
|
||||
|
||||
model := resolveRunModel()
|
||||
log.Info("github run: handling trigger",
|
||||
"repo", tr.repo, "number", tr.number, "pr", tr.isPR, "author", tr.author, "model", model)
|
||||
|
||||
// React with 👀 so the human sees Kit picked up the request.
|
||||
addReaction(ctx, tr, "eyes")
|
||||
|
||||
gathered := gatherContext(ctx, tr)
|
||||
prompt := buildPrompt(tr, gathered)
|
||||
|
||||
response, runErr := runAgent(ctx, model, prompt)
|
||||
if runErr != nil {
|
||||
postComment(ctx, tr, "⚠️ Kit hit an error while processing this request:\n\n```\n"+runErr.Error()+"\n```")
|
||||
addReaction(ctx, tr, "confused")
|
||||
return runErr
|
||||
}
|
||||
|
||||
response = strings.TrimSpace(response)
|
||||
if response == "" {
|
||||
response = "Kit finished without a textual response."
|
||||
}
|
||||
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRunModel picks the model: --model flag, then $MODEL, then the default.
|
||||
func resolveRunModel() string {
|
||||
if m := strings.TrimSpace(githubRunModel); m != "" {
|
||||
return m
|
||||
}
|
||||
if m := strings.TrimSpace(os.Getenv("MODEL")); m != "" {
|
||||
return m
|
||||
}
|
||||
return defaultGitHubModel
|
||||
}
|
||||
|
||||
func inGitHubActions() bool {
|
||||
return os.Getenv("GITHUB_ACTIONS") == "true"
|
||||
}
|
||||
|
||||
// githubDryRun reports whether side effects should be logged instead of run.
|
||||
func githubDryRun() bool {
|
||||
return githubRunDryRun || os.Getenv("KIT_GITHUB_DRY_RUN") != ""
|
||||
}
|
||||
|
||||
// loadGitHubEvent reads and decodes the GitHub Actions event payload.
|
||||
func loadGitHubEvent() (*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
|
||||
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
|
||||
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.SplitSeq(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 comment thread and PR diff.
|
||||
func gatherContext(ctx context.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 githubDryRun() || !commandExists("gh") {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
num := fmt.Sprint(tr.number)
|
||||
if tr.isPR {
|
||||
if diff := ghOutput(ctx, "pr", "diff", num, "--repo", tr.repo); diff != "" {
|
||||
fmt.Fprintf(&b, "\n## Diff\n```diff\n%s\n```\n", strings.TrimSpace(diff))
|
||||
}
|
||||
if comments := ghOutput(ctx, "pr", "view", num, "--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", num, "--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, gathered 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(gathered))
|
||||
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()
|
||||
}
|
||||
|
||||
// runAgent drives the agent headlessly by invoking this same binary in quiet,
|
||||
// ephemeral mode against the constructed prompt, and returns its response. In
|
||||
// dry-run it returns a canned response without spawning anything.
|
||||
func runAgent(ctx context.Context, model, prompt string) (string, error) {
|
||||
if githubDryRun() {
|
||||
log.Info("github run: [dry-run] would run agent", "model", model, "promptChars", len(prompt))
|
||||
return "[dry-run] agent response", nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil || exe == "" {
|
||||
exe = "kit"
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, agentTimeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{"--quiet", "--no-session", "--no-extensions"}
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
args = append(args, prompt)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, exe, args...)
|
||||
cmd.Stderr = os.Stderr // surface agent progress/errors in the Actions log
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("agent run failed: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// hasUncommittedChanges reports whether the agent produced working-tree changes.
|
||||
func hasUncommittedChanges(ctx context.Context) bool {
|
||||
if githubDryRun() {
|
||||
return os.Getenv("KIT_GITHUB_FAKE_DIRTY") != ""
|
||||
}
|
||||
return strings.TrimSpace(gitOutput(ctx, "status", "--porcelain")) != ""
|
||||
}
|
||||
|
||||
// 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 context.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))
|
||||
|
||||
// `persist-credentials: false` in the workflow means the checkout left no
|
||||
// push credentials behind. Re-establish them from GITHUB_TOKEN via gh's git
|
||||
// credential helper, then push over the existing origin remote.
|
||||
if !githubDryRun() {
|
||||
runCmd(ctx, "gh", "auth", "setup-git")
|
||||
}
|
||||
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 githubDryRun() {
|
||||
log.Info("github run: [dry-run] would open PR", "branch", branch, "base", tr.defaultBranch)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(ghOutput(ctx, "pr", "create", "--repo", tr.repo,
|
||||
"--head", branch, "--base", tr.defaultBranch, "--title", title, "--body", body))
|
||||
}
|
||||
|
||||
// addReaction adds an emoji reaction to the trigger comment.
|
||||
func addReaction(ctx context.Context, tr *trigger, content string) {
|
||||
path := fmt.Sprintf("/repos/%s/%s/comments/%d/reactions", tr.repo, tr.commentKind, tr.commentID)
|
||||
if githubDryRun() || !commandExists("gh") {
|
||||
log.Info("github run: [dry-run] react", "content", content, "path", 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 context.Context, tr *trigger, body string) {
|
||||
sub := "issue"
|
||||
if tr.isPR {
|
||||
sub = "pr"
|
||||
}
|
||||
if githubDryRun() || !commandExists("gh") {
|
||||
log.Info("github run: [dry-run] comment", "sub", sub, "number", tr.number, "chars", 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 context.Context, args ...string) {
|
||||
if githubDryRun() {
|
||||
log.Info("github run: [dry-run] git", "args", strings.Join(args, " "))
|
||||
return
|
||||
}
|
||||
runCmd(ctx, "git", args...)
|
||||
}
|
||||
|
||||
// gitOutput runs a read-only git command and returns its stdout.
|
||||
func gitOutput(ctx context.Context, args ...string) string {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(cmdCtx, "git", args...).Output()
|
||||
if err != nil {
|
||||
log.Error("github run: git failed", "args", strings.Join(args, " "), "err", err)
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ghOutput runs a gh command and returns its stdout.
|
||||
func ghOutput(ctx context.Context, args ...string) string {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(cmdCtx, "gh", args...).Output()
|
||||
if err != nil {
|
||||
log.Error("github run: gh failed", "args", strings.Join(args, " "), "err", err)
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// runCmd runs a command for its side effects, surfacing failures in the log.
|
||||
func runCmd(ctx context.Context, name string, args ...string) {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
|
||||
defer cancel()
|
||||
if out, err := exec.CommandContext(cmdCtx, name, args...).CombinedOutput(); err != nil {
|
||||
log.Error("github run: command failed", "cmd", name, "err", err, "output", strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupEvent writes a GitHub event payload to a temp file, points
|
||||
// GITHUB_EVENT_PATH at it, and forces dry-run + Actions mode. It also resets
|
||||
// the run command's package-level flag state so tests are independent.
|
||||
func setupEvent(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)
|
||||
t.Cleanup(func() {
|
||||
githubRunModel = ""
|
||||
githubRunDryRun = false
|
||||
})
|
||||
}
|
||||
|
||||
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 TestExtractRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
wantHit bool
|
||||
}{
|
||||
{"start with request", "/kit fix the bug", "fix the bug", true},
|
||||
{"bare token", "/kit", "", true},
|
||||
{"trailing token", "hey /kit", "", true},
|
||||
{"mid-sentence ignored", "please review /kit behavior in the docs", "", false},
|
||||
{"no token", "just a normal comment", "", false},
|
||||
{"token in second line", "thanks!\n/kit add tests", "add tests", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, hit := extractRequest(tt.body)
|
||||
if hit != tt.wantHit || got != tt.want {
|
||||
t.Errorf("extractRequest(%q) = (%q, %v), want (%q, %v)", tt.body, got, hit, tt.want, tt.wantHit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrigger_IssueComment(t *testing.T) {
|
||||
event, err := func() (*ghEvent, error) {
|
||||
setupEvent(t, issueCommentEvent)
|
||||
return loadGitHubEvent()
|
||||
}()
|
||||
if err != nil {
|
||||
t.Fatalf("loadGitHubEvent: %v", err)
|
||||
}
|
||||
tr, err := buildTrigger(event)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTrigger: %v", err)
|
||||
}
|
||||
if tr.repo != "acme/widgets" || tr.number != 42 || tr.isPR || tr.request != "fix the broken parser" {
|
||||
t.Errorf("unexpected trigger: %+v", tr)
|
||||
}
|
||||
if tr.commentKind != "issues" {
|
||||
t.Errorf("commentKind = %q, want issues", tr.commentKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_ContainsContext(t *testing.T) {
|
||||
setupEvent(t, issueCommentEvent)
|
||||
event, _ := loadGitHubEvent()
|
||||
tr, _ := buildTrigger(event)
|
||||
|
||||
prompt := buildPrompt(tr, gatherContext(context.Background(), tr))
|
||||
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 TestRunGitHub_AuthorizedIssueComment(t *testing.T) {
|
||||
setupEvent(t, issueCommentEvent)
|
||||
if err := runGitHubRun(githubRunCmd, nil); err != nil {
|
||||
t.Fatalf("runGitHubRun: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGitHub_UnauthorizedAssociation(t *testing.T) {
|
||||
setupEvent(t, strings.Replace(issueCommentEvent, `"OWNER"`, `"NONE"`, 1))
|
||||
// Should return nil (no-op) without attempting the agent run.
|
||||
if err := runGitHubRun(githubRunCmd, nil); err != nil {
|
||||
t.Fatalf("runGitHubRun should be a no-op for unauthorized authors, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGitHub_CommentWithoutToken(t *testing.T) {
|
||||
setupEvent(t, strings.Replace(issueCommentEvent,
|
||||
`"/kit fix the broken parser"`, `"just a normal comment"`, 1))
|
||||
if err := runGitHubRun(githubRunCmd, nil); err != nil {
|
||||
t.Fatalf("runGitHubRun should be a no-op without /kit, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGitHub_MidSentenceMentionIgnored(t *testing.T) {
|
||||
setupEvent(t, strings.Replace(issueCommentEvent,
|
||||
`"/kit fix the broken parser"`, `"please review /kit behavior in the docs"`, 1))
|
||||
if err := runGitHubRun(githubRunCmd, nil); err != nil {
|
||||
t.Fatalf("runGitHubRun should ignore mid-sentence mentions, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGitHub_PullRequestReviewComment(t *testing.T) {
|
||||
setupEvent(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"}
|
||||
}`)
|
||||
event, _ := loadGitHubEvent()
|
||||
tr, err := buildTrigger(event)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTrigger: %v", err)
|
||||
}
|
||||
if !tr.isPR || tr.number != 7 || tr.commentKind != "pulls" {
|
||||
t.Errorf("unexpected PR trigger: %+v", tr)
|
||||
}
|
||||
if err := runGitHubRun(githubRunCmd, nil); err != nil {
|
||||
t.Fatalf("runGitHubRun (PR): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGitHub_RequiresActionsOrDryRun(t *testing.T) {
|
||||
// Neither GITHUB_ACTIONS nor dry-run set → must error rather than act.
|
||||
t.Setenv("GITHUB_ACTIONS", "")
|
||||
t.Setenv("KIT_GITHUB_DRY_RUN", "")
|
||||
githubRunDryRun = false
|
||||
t.Cleanup(func() { githubRunDryRun = false })
|
||||
if err := runGitHubRun(githubRunCmd, nil); err == nil {
|
||||
t.Fatal("expected an error when run outside Actions without --dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRunModel(t *testing.T) {
|
||||
t.Cleanup(func() { githubRunModel = "" })
|
||||
|
||||
t.Setenv("MODEL", "")
|
||||
githubRunModel = ""
|
||||
if got := resolveRunModel(); got != defaultGitHubModel {
|
||||
t.Errorf("default model = %q, want %q", got, defaultGitHubModel)
|
||||
}
|
||||
|
||||
t.Setenv("MODEL", "openai/gpt-5")
|
||||
if got := resolveRunModel(); got != "openai/gpt-5" {
|
||||
t.Errorf("MODEL env model = %q, want openai/gpt-5", got)
|
||||
}
|
||||
|
||||
githubRunModel = "anthropic/claude-sonnet-4-5"
|
||||
if got := resolveRunModel(); got != "anthropic/claude-sonnet-4-5" {
|
||||
t.Errorf("flag model = %q, want anthropic/claude-sonnet-4-5", got)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@ func TestRenderGitHubWorkflow(t *testing.T) {
|
||||
"github.event.comment.author_association == 'OWNER'",
|
||||
"github.event.comment.author_association == 'COLLABORATOR'",
|
||||
"persist-credentials: false",
|
||||
"uses: mark3labs/kit-action@v1",
|
||||
"uses: mark3labs/kit@v0",
|
||||
"model: anthropic/claude-sonnet-4-5-20250929",
|
||||
"GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
|
||||
"ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -99,6 +99,8 @@ The generated workflow:
|
||||
|
||||
After committing the workflow and setting the provider secret, comment `/kit <your request>` on any issue or pull request to trigger Kit.
|
||||
|
||||
The generated workflow uses the bundled [`mark3labs/kit`](https://github.com/mark3labs/kit/blob/master/action.yml) composite action, which installs the Kit binary and runs `kit github run`. That command reads the triggering event, enforces permissions, reacts with an emoji, runs the agent against the issue thread or PR, posts the response as a comment, and — if the agent changed files — pushes a `kit-agent[bot]` branch and opens a pull request.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--model` | Provider/model to write into the workflow |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user