Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/reusable-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,7 @@ jobs:
with:
agent: code
version: ${{ inputs.fullsend_version }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
status-repo: ${{ inputs.source_repo }}
status-number: ${{ fromJSON(inputs.event_payload).issue.number }}
status-token: ${{ steps.app-token.outputs.token }}
4 changes: 4 additions & 0 deletions .github/workflows/reusable-fix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,7 @@ jobs:
with:
agent: fix
version: ${{ inputs.fullsend_version }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
status-repo: ${{ inputs.source_repo }}
status-number: ${{ steps.context.outputs.pr_number }}
status-token: ${{ steps.app-token.outputs.token }}
4 changes: 4 additions & 0 deletions .github/workflows/reusable-retro.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,7 @@ jobs:
with:
agent: retro
version: ${{ inputs.fullsend_version }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
status-repo: ${{ inputs.source_repo }}
status-number: ${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}
status-token: ${{ steps.app-token.outputs.token }}
4 changes: 4 additions & 0 deletions .github/workflows/reusable-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,7 @@ jobs:
with:
agent: review
version: ${{ inputs.fullsend_version }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
status-repo: ${{ inputs.source_repo }}
status-number: ${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}
status-token: ${{ steps.app-token.outputs.token }}
4 changes: 4 additions & 0 deletions .github/workflows/reusable-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,7 @@ jobs:
with:
agent: triage
version: ${{ inputs.fullsend_version }}
run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
status-repo: ${{ inputs.source_repo }}
status-number: ${{ fromJSON(inputs.event_payload).issue.number }}
status-token: ${{ steps.app-token.outputs.token }}
32 changes: 31 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ inputs:
description: >-
GitHub token for authenticated API calls (avoids 60 req/hour unauthenticated rate limit).
default: ${{ github.token }}
run-url:
description: URL of the CI/CD run for status comments (optional).
default: ""
status-repo:
description: Repository (owner/repo) for status comments (optional).
default: ""
status-number:
description: Issue/PR number for status comments (optional).
default: ""
status-token:
description: Token for status comments (defaults to GH_TOKEN env var).
default: ""

runs:
using: composite
Expand Down Expand Up @@ -262,6 +274,10 @@ runs:
AGENT: ${{ inputs.agent }}
FULLSEND_DIR: ${{ inputs.fullsend-dir }}
TARGET_REPO: ${{ inputs.target-repo }}
STATUS_RUN_URL: ${{ inputs.run-url }}
STATUS_REPO: ${{ inputs.status-repo }}
STATUS_NUMBER: ${{ inputs.status-number }}
STATUS_TOKEN: ${{ inputs.status-token }}
Comment thread
ggallen marked this conversation as resolved.
run: |
set -euo pipefail
FULLSEND_DIR="${FULLSEND_DIR:-${GITHUB_WORKSPACE}}"
Expand All @@ -271,10 +287,24 @@ runs:
# Post-scripts enforce secret scanning, protected-path blocks,
# and review-downgrade controls. Skipping them in CI bypasses
# all post-push security gates.
if [[ -n "${STATUS_TOKEN}" ]]; then
echo "::add-mask::${STATUS_TOKEN}"
fi
STATUS_FLAGS=()
if [[ -n "${STATUS_REPO}" && -n "${STATUS_NUMBER}" ]]; then
STATUS_FLAGS+=(--status-repo "${STATUS_REPO}" --status-number "${STATUS_NUMBER}")
if [[ -n "${STATUS_RUN_URL}" ]]; then
STATUS_FLAGS+=(--run-url "${STATUS_RUN_URL}")
fi
if [[ -n "${STATUS_TOKEN}" ]]; then
STATUS_FLAGS+=(--status-token "${STATUS_TOKEN}")
fi
fi
fullsend run "${AGENT}" \
--fullsend-dir "${FULLSEND_DIR}" \
--output-dir "${GITHUB_WORKSPACE}/output" \
--target-repo "${TARGET_REPO}"
--target-repo "${TARGET_REPO}" \
"${STATUS_FLAGS[@]+"${STATUS_FLAGS[@]}"}"

- name: Upload fullsend artifacts
if: always() && inputs.agent != '__install_only__'
Expand Down
11 changes: 11 additions & 0 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ fullsend
│ ├── uninstall <org> # Remove fullsend GitHub configuration
│ └── sync-scaffold <org> # Update workflow templates
├── run # Execute an agent in a sandbox
│ ├── --fullsend-dir <path> # Base directory with .fullsend layout
│ ├── --target-repo <path> # Path to the target repository
│ ├── --output-dir <path> # Base directory for run output
│ ├── --env-file <path> # Load env vars from dotenv file (repeatable)
│ ├── --no-post-script # Skip post-script execution
│ ├── --debug [filter] # Enable Claude Code debug logging
│ ├── --offline # Reject network fetches
│ ├── --run-url <url> # CI/CD run URL for status comments
│ ├── --status-repo <owner/repo> # Repository for status comments
│ ├── --status-number <int> # Issue/PR number for status comments
│ └── --status-token <token> # Token for status comments (default: GH_TOKEN)
├── scan # Run security scanner on input/output
│ ├── input # Scan event payload for prompt injection
│ ├── output # Scan agent output for leaked secrets
Expand Down
27 changes: 27 additions & 0 deletions docs/guides/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,33 @@ fullsend admin install "$ORG_NAME" \

> **Note:** IAM policy bindings may take several minutes to propagate. If agent workflows fail with a permission error immediately after setup, wait a few minutes and retry.

## Status notifications

Agent workflows post status comments on issues and PRs when they start and
complete. This behavior is controlled by the `status_notifications` section in
`config.yaml`:

```yaml
defaults:
status_notifications:
comment:
start: enabled # "enabled" (default) | "disabled"
completion: enabled # "enabled" (default) | "disabled"
```

When `status_notifications` is omitted, comments default to enabled.

The composite action accepts four optional inputs for status notifications:

| Input | Description |
|-------|-------------|
| `run-url` | URL of the CI/CD run shown in the status comment |
| `status-repo` | Repository (`owner/repo`) to post status comments on |
| `status-number` | Issue or PR number for status comments |
| `status-token` | Token for posting comments (defaults to `GH_TOKEN`) |

All reusable workflows pass these inputs automatically.

## See Also

- [Setting up with pre-provisioned infrastructure](github-setup.md) — GitHub-only setup when GCP is already provisioned
Expand Down
28 changes: 28 additions & 0 deletions docs/guides/user/running-agents-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,34 @@ fullsend run code \
--env-file fullsend-code.env
```

### Status notification flags

When running agents locally you can optionally enable status comments on the
target issue/PR. These flags mirror what the CI workflows pass automatically:

| Flag | Description |
|------|-------------|
| `--run-url` | URL of the CI/CD run shown in the status comment |
| `--status-repo` | Repository (`owner/repo`) to post status comments on |
| `--status-number` | Issue or PR number for status comments |
| `--status-token` | Token for posting comments (defaults to `GH_TOKEN`) |

Example:

```bash
fullsend run triage \
--fullsend-dir /tmp/fullsend-ai_fullsend/internal/scaffold/fullsend-repo/ \
--target-repo /tmp/target-repo/ \
--env-file fullsend-gcp.env \
--env-file fullsend-triage.env \
--status-repo myorg/myrepo \
--status-number 42 \
--run-url "https://github.com/myorg/myrepo/actions/runs/12345"
```

Status comment behavior is configured via `status_notifications` in
`config.yaml`. See the [installation guide](../getting-started/installation.md#status-notifications).

## Simulating Fullsend's real customization layers

Fullsend automatically aggregates different layers of information before running `fullsend run`.
Expand Down
104 changes: 102 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ import (
"github.com/fullsend-ai/fullsend/internal/config"
"github.com/fullsend-ai/fullsend/internal/envfile"
"github.com/fullsend-ai/fullsend/internal/fetch"
gh "github.com/fullsend-ai/fullsend/internal/forge/github"
"github.com/fullsend-ai/fullsend/internal/harness"
"github.com/fullsend-ai/fullsend/internal/resolve"
agentruntime "github.com/fullsend-ai/fullsend/internal/runtime"
"github.com/fullsend-ai/fullsend/internal/sandbox"
"github.com/fullsend-ai/fullsend/internal/scaffold"
"github.com/fullsend-ai/fullsend/internal/security"
"github.com/fullsend-ai/fullsend/internal/statuscomment"
"github.com/fullsend-ai/fullsend/internal/ui"
)

Expand All @@ -51,6 +53,14 @@ var agentWorkingDirExcludes = []string{
".fullsend-workspace/",
}

// statusOpts holds the optional status notification parameters for a run.
type statusOpts struct {
runURL string
statusRepo string
statusNum int
statusToken string
}

func newRunCmd() *cobra.Command {
var fullsendDir string
var outputBase string
Expand All @@ -61,6 +71,7 @@ func newRunCmd() *cobra.Command {
var debugFilter string
var offline bool
var keepSandbox bool
var sOpts statusOpts

cmd := &cobra.Command{
Use: "run <agent-name>",
Expand All @@ -70,7 +81,7 @@ func newRunCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
agentName := args[0]
printer := ui.New(os.Stdout)
return runAgent(cmd.Context(), agentName, fullsendDir, outputBase, targetRepo, fullsendBinary, envFiles, noPostScript, debugFilter, offline, printer, keepSandbox)
return runAgent(cmd.Context(), agentName, fullsendDir, outputBase, targetRepo, fullsendBinary, envFiles, noPostScript, debugFilter, offline, sOpts, printer, keepSandbox)
},
}

Expand All @@ -84,13 +95,17 @@ func newRunCmd() *cobra.Command {
cmd.Flags().StringVar(&debugFilter, "debug", "", `enable Claude Code debug logging with optional category filter (e.g. "api,hooks")`)
cmd.Flags().Lookup("debug").NoOptDefVal = "*"
cmd.Flags().BoolVar(&offline, "offline", false, "reject network fetches; only use cached remote resources")
cmd.Flags().StringVar(&sOpts.runURL, "run-url", "", "URL of the CI/CD run for status comments")
cmd.Flags().StringVar(&sOpts.statusRepo, "status-repo", "", "repository (owner/repo) for status comments")
cmd.Flags().IntVar(&sOpts.statusNum, "status-number", 0, "issue/PR number for status comments")
cmd.Flags().StringVar(&sOpts.statusToken, "status-token", "", "token for status comments (defaults to GH_TOKEN)")
_ = cmd.MarkFlagRequired("fullsend-dir")
_ = cmd.MarkFlagRequired("target-repo")

return cmd
}

func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRepo, fullsendBinary string, envFiles []string, noPostScript bool, debug string, offline bool, printer *ui.Printer, keepSandbox bool) (runErr error) {
func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRepo, fullsendBinary string, envFiles []string, noPostScript bool, debug string, offline bool, sOpts statusOpts, printer *ui.Printer, keepSandbox bool) (runErr error) {
printer.Banner(Version())
printer.Blank()
printer.Header("Running agent: " + agentName)
Expand Down Expand Up @@ -261,6 +276,38 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
}
}

// 1c. Set up status notifications (comments on the issue/PR).
// Lives in the CLI layer (not harness or post-script) so it wraps the
// entire run lifecycle including sandbox setup, validation loop, and
// post-script — and can report cancellation/failure even when the
// sandbox never starts. See #1859.
if sOpts.statusRepo != "" && sOpts.statusNum > 0 {
notifier, notifyErr := setupStatusNotifier(absFullsendDir, sOpts, printer)
if notifyErr != nil {
printer.StepWarn("Status notifications disabled: " + notifyErr.Error())
} else {
description := titleCase(strings.ReplaceAll(agentName, "-", " "))
if err := notifier.PostStart(ctx, description); err != nil {
printer.StepWarn("Failed to post start status: " + err.Error())
} else {
printer.StepDone("Posted start status comment")
}
defer func() {
status := "success"
if ctx.Err() != nil {
status = "cancelled"
} else if runErr != nil {
status = "failure"
}
dCtx, dCancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer dCancel()
if err := notifier.PostCompletion(dCtx, description, status); err != nil {
printer.StepWarn("Failed to post completion status: " + err.Error())
}
}()
}
}

// 2. Check openshell availability.
openshellStart := time.Now()
printer.StepStart("Checking openshell availability")
Expand Down Expand Up @@ -1822,3 +1869,56 @@ func crossCompileFullsend(arch, destPath string) error {
}
return nil
}

func titleCase(s string) string {
words := strings.Fields(s)
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, " ")
}

func setupStatusNotifier(fullsendDir string, sOpts statusOpts, printer *ui.Printer) (*statuscomment.Notifier, error) {
parts := strings.SplitN(sOpts.statusRepo, "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("--status-repo must be in owner/repo format, got %q", sOpts.statusRepo)
}
owner, repo := parts[0], parts[1]

token := sOpts.statusToken
if token == "" {
token = os.Getenv("GH_TOKEN")
}
if token == "" {
return nil, fmt.Errorf("no status token available (set --status-token or GH_TOKEN)")
}

var notifyCfg config.StatusNotificationConfig
orgConfigPath := filepath.Join(fullsendDir, "config.yaml")
if data, err := os.ReadFile(orgConfigPath); err == nil {
orgCfg, parseErr := config.ParseOrgConfig(data)
if parseErr != nil {
printer.StepWarn("Failed to parse config.yaml for status notifications: " + parseErr.Error())
} else if orgCfg.Defaults.StatusNotifications != nil {
notifyCfg = *orgCfg.Defaults.StatusNotifications
}
} else if !os.IsNotExist(err) {
printer.StepWarn("Failed to read config.yaml for status notifications: " + err.Error())
}

client := gh.New(token)

sha := os.Getenv("GITHUB_SHA")
runID := os.Getenv("GITHUB_RUN_ID")
if runID == "" {
runID = fmt.Sprintf("%d", time.Now().UnixNano())
}

n := statuscomment.New(client, notifyCfg, owner, repo, sOpts.statusNum, sOpts.runURL, sha, runID)
n.SetWarnFunc(func(format string, args ...any) {
printer.StepWarn(fmt.Sprintf(format, args...))
})
return n, nil
}
Loading
Loading