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
8 changes: 6 additions & 2 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ fullsend
│ ├── --tracker <tracker> # Tracker backend: github, gitlab, or jira
│ ├── --project <project> # Project: owner/repo (GitHub/GitLab) or key (Jira)
│ ├── --number <int> # Issue number
│ └── --marker <string> # Sticky marker for idempotent updates (HTML comment or Jira property)
│ ├── --marker <string> # Sticky marker for idempotent updates (HTML comment or Jira property)
│ ├── --keep-history # Append previous content as collapsed history (default true)
│ └── --fullsend-dir <path> # .fullsend config directory (resolves keep_history default)
├── post-review # Post PR/MR review comments to GitHub or GitLab
│ ├── --forge <forge> # Forge backend: github (default) or gitlab
│ ├── --base-url <url> # Forge instance URL (e.g. https://gitlab.example.com)
Expand All @@ -142,7 +144,9 @@ fullsend
│ ├── --result <path> # Path to review result file, or '-' for stdin
│ ├── --token <string> # Forge token (default: $GH_TOKEN / $GITHUB_TOKEN or $GITLAB_TOKEN)
│ ├── --head-sha <sha> # Expected PR HEAD SHA (skips review if HEAD moved)
│ └── --dry-run # Print what would be posted without API calls
│ ├── --dry-run # Print what would be posted without API calls
│ ├── --keep-history # Append previous content as collapsed history (default true)
│ └── --fullsend-dir <path> # .fullsend config directory (default: $FULLSEND_DIR; resolves keep_history default)
├── post-comment # Post issue/PR comments to GitHub (deprecated)
├── eval-measure # Score wild-run traces (eval measurements)
│ ├── --telemetry <path> # Path to run-telemetry.jsonl (or --output-dir)
Expand Down
16 changes: 16 additions & 0 deletions docs/guides/infrastructure/layered-config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ the overlay → base → code defaults chain.
| `version` | `string` | Scalar override | `"1"` |
| `runtime` | `string` | Scalar override | `"claude"` |
| `kill_switch` | `*bool` | Scalar override | `false` (inactive) |
| `keep_history` | `*bool` | Scalar override | `true` (history appended) |
| `roles` | `[]string` | Replace if set | `PerRepoDefaultRoles()` |
| `agents` | `[]AgentEntry` | Keyed merge by `DerivedName()` | `nil` (none) |
| `allowed_remote_resources` | `[]string` | Union with deny-all | `DefaultAllowedRemoteResources()` |
Expand Down Expand Up @@ -137,6 +138,20 @@ unset, the accessor falls through to the base layer, then to code defaults.
- `*false` (explicit `kill_switch: false`) — locally set to inactive.
Does **not** fall through.
- `*true` (explicit `kill_switch: true`) — locally set to active.
- **`keep_history`**: Pointer to bool (`*bool`). Controls whether sticky
comment updates (from `post-review`, `post-comment`, and
`issues post-comment`) append the previous body as a collapsed
"Previous run" `<details>` block. Uses the same three-state pointer
semantics as `kill_switch`:
- `nil` (key omitted) — unset, falls through to parent.
Code default is `true` (history appended, preserving existing
behavior).
- `*true` (explicit `keep_history: true`) — updates collapse old
content into history blocks.
- `*false` (explicit `keep_history: false`) — updates replace the
comment body in-place with no history. Useful when accumulated
"Previous run" blocks add unwanted noise (e.g., when comments are
synced to Jira where `<details>` does not render as collapsible).

### `mint_url` and `inference` — scalar override (ADR 0069 Decision 1)

Expand Down Expand Up @@ -343,6 +358,7 @@ compiled-in defaults apply:
| `version` | `"1"` |
| `runtime` | `"claude"` |
| `kill_switch` | `false` (inactive) |
| `keep_history` | `true` (history appended) |
| `roles` | `["triage", "coder", "review", "fix", "retro", "prioritize"]` |
| `agents` | `nil` (none configured) |
| `allowed_remote_resources` | `["https://raw.githubusercontent.com/fullsend-ai/fullsend/", "https://raw.githubusercontent.com/fullsend-ai/agents/"]` |
Expand Down
13 changes: 8 additions & 5 deletions docs/guides/user/issues-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,16 @@ fullsend issues get \
| `--token` | No | API token (default: env var per tracker) |
| `--jira-url` | Jira only | Jira instance URL (default: `$JIRA_BASE_URL`) |
| `--jira-email` | Jira only | Jira user email for auth (default: `$JIRA_USER_EMAIL`) |
| `--fullsend-dir` | No | Path to `.fullsend` config directory (sources a default `--tracker` from its `config.yaml`) |
| `--fullsend-dir` | No | Path to `.fullsend` config directory (sources defaults from its `config.yaml` when flags are omitted) |

## `fullsend issues post-comment`

Posts a comment with a sticky marker on an issue. On re-runs, finds
the existing comment by its marker and edits in-place, collapsing old
content into `<details>` blocks. This prevents comment flooding on
re-runs. For GitHub and GitLab, the marker is embedded as an invisible
the existing comment by its marker and edits in-place. By default,
old content is collapsed into `<details>` blocks to preserve history;
set `keep_history: false` in config.yaml (or pass `--keep-history=false`)
to replace the body with no history.
This prevents comment flooding on re-runs. For GitHub and GitLab, the marker is embedded as an invisible
HTML comment in the body. For Jira, the marker is stored as a comment
entity property (Jira has no HTML comments, so a body-embedded marker
would be visible to users).
Expand Down Expand Up @@ -84,7 +86,8 @@ echo "Triage complete. See PR #99." | fullsend issues post-comment \
| `--jira-url` | Jira only | Jira instance URL (default: `$JIRA_BASE_URL`) |
| `--jira-email` | Jira only | Jira user email for auth (default: `$JIRA_USER_EMAIL`) |
| `--dry-run` | No | Print what would be posted without making API calls |
| `--fullsend-dir` | No | Path to `.fullsend` config directory (sources a default `--tracker` from its `config.yaml`) |
| `--keep-history` | No | Append previous content as collapsed history blocks (default: `true`; set `false` to replace in-place) |
| `--fullsend-dir` | No | Path to `.fullsend` config directory (sources defaults from its `config.yaml` when flags are omitted) |

### Jira marker storage

Expand Down
45 changes: 41 additions & 4 deletions internal/cli/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ type issuesPostCommentConfig struct {
jiraURL string
jiraEmail string
dryRun bool
keepHistory *bool // nil = resolve from config; non-nil = explicit flag
fullsendDir string

// Test overrides — when non-nil, used instead of creating a real
Expand All @@ -189,7 +190,10 @@ type issuesPostCommentConfig struct {
}

func newIssuesPostCommentCmd() *cobra.Command {
var cfg issuesPostCommentConfig
var (
cfg issuesPostCommentConfig
keepHistory bool
)

cmd := &cobra.Command{
Use: "post-comment",
Expand Down Expand Up @@ -222,6 +226,9 @@ pointing at the directory containing it.

The --result flag accepts a file path or "-" for stdin.`,
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("keep-history") {
cfg.keepHistory = &keepHistory
}
return runIssuesPostComment(cmd.Context(), &cfg)
},
}
Expand All @@ -235,7 +242,8 @@ The --result flag accepts a file path or "-" for stdin.`,
cmd.Flags().StringVar(&cfg.jiraURL, "jira-url", "", "Jira instance URL (default: $JIRA_BASE_URL)")
cmd.Flags().StringVar(&cfg.jiraEmail, "jira-email", "", "Jira user email for Basic auth (default: $JIRA_USER_EMAIL)")
cmd.Flags().BoolVar(&cfg.dryRun, "dry-run", false, "print what would be posted without making API calls")
cmd.Flags().StringVar(&cfg.fullsendDir, "fullsend-dir", "", "path to .fullsend config directory (sources a default --tracker from its config.yaml when --tracker is omitted)")
cmd.Flags().BoolVar(&keepHistory, "keep-history", true, "append previous content as collapsed history blocks (set false to replace in-place)")
cmd.Flags().StringVar(&cfg.fullsendDir, "fullsend-dir", "", "path to .fullsend config directory (sources defaults from its config.yaml when flags are omitted)")
_ = cmd.MarkFlagRequired("project")
_ = cmd.MarkFlagRequired("number")
_ = cmd.MarkFlagRequired("marker")
Expand Down Expand Up @@ -279,9 +287,15 @@ func runIssuesPostComment(ctx context.Context, cfg *issuesPostCommentConfig) err

printer.Header("Post Comment")

keepHistory, err := resolveKeepHistory(cfg.keepHistory, cfg.fullsendDir, cfg.testConfigReader)
if err != nil {
printer.StepWarn(fmt.Sprintf("Warning: %v; defaulting to keep_history=true", err))
}

stickyCfg := sticky.Config{
Marker: cfg.marker,
DryRun: cfg.dryRun,
Marker: cfg.marker,
DryRun: cfg.dryRun,
KeepHistory: keepHistory,
}
if trackerName == trackerJira {
// The Jira write path routes every body through
Expand Down Expand Up @@ -478,6 +492,29 @@ func validateTrackerName(name string) (string, error) {
return normalized, nil
Comment thread
ralphbean marked this conversation as resolved.
}

// resolveKeepHistory returns the explicit flag value if non-nil, otherwise
// resolves the keep_history setting from config.yaml via fullsendDir. If
// neither source provides a value, defaults to true (current behavior).
// Returns an error when config loading fails so callers can surface it
// (matching the pattern in resolveTracker).
func resolveKeepHistory(flag *bool, fullsendDir string, testConfigReader config.PerRepoConfigReader) (bool, error) {
if flag != nil {
return *flag, nil
}
prc := testConfigReader
Comment thread
ralphbean marked this conversation as resolved.
if prc == nil && fullsendDir != "" {
reader, err := config.LoadConfig(fullsendDir, config.LoadOpts{MissingOK: true})
if err != nil {
return true, fmt.Errorf("loading config for keep_history: %w", err)
}
prc, _ = reader.(config.PerRepoConfigReader)
}
if prc != nil {
return prc.ConfigKeepHistory(), nil
}
return true, nil
}

// findMarkedTrackerComment returns the first tracker comment whose body
// contains the given marker string, or nil if none is found. This is
// the tracker.Comment equivalent of sticky.FindMarkedComment.
Expand Down
70 changes: 60 additions & 10 deletions internal/cli/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func TestPostTrackerStickyComment_Create(t *testing.T) {
tc := tracker.NewForgeClient(fc)

printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->"}
cfg := sticky.Config{Marker: "<!-- test -->", KeepHistory: true}

url, err := postTrackerStickyComment(context.Background(), tc, "acme/widgets", 42, "hello world", cfg, printer)
require.NoError(t, err)
Expand All @@ -125,7 +125,7 @@ func TestPostTrackerStickyComment_Update(t *testing.T) {
tc := tracker.NewForgeClient(fc)

printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->"}
cfg := sticky.Config{Marker: "<!-- test -->", KeepHistory: true}
ctx := context.Background()

// First post creates the comment.
Expand All @@ -148,7 +148,7 @@ func TestPostTrackerStickyComment_EmptyBody(t *testing.T) {
fc := forge.NewFakeClient()
tc := tracker.NewForgeClient(fc)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->"}
cfg := sticky.Config{Marker: "<!-- test -->", KeepHistory: true}

_, err := postTrackerStickyComment(context.Background(), tc, "acme/widgets", 42, "", cfg, printer)
assert.Error(t, err)
Expand All @@ -159,7 +159,7 @@ func TestPostTrackerStickyComment_EmptyMarker(t *testing.T) {
fc := forge.NewFakeClient()
tc := tracker.NewForgeClient(fc)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: ""}
cfg := sticky.Config{Marker: "", KeepHistory: true}

_, err := postTrackerStickyComment(context.Background(), tc, "acme/widgets", 42, "hello", cfg, printer)
assert.Error(t, err)
Expand All @@ -170,7 +170,7 @@ func TestPostTrackerStickyComment_DryRun_Create(t *testing.T) {
fc := forge.NewFakeClient()
tc := tracker.NewForgeClient(fc)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->", DryRun: true}
cfg := sticky.Config{Marker: "<!-- test -->", DryRun: true, KeepHistory: true}

url, err := postTrackerStickyComment(context.Background(), tc, "acme/widgets", 42, "hello", cfg, printer)
require.NoError(t, err)
Expand All @@ -187,7 +187,7 @@ func TestPostTrackerStickyComment_DryRun_Update(t *testing.T) {
fc.AuthenticatedUser = "bot"
tc := tracker.NewForgeClient(fc)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->"}
cfg := sticky.Config{Marker: "<!-- test -->", KeepHistory: true}
ctx := context.Background()

// Create the initial comment (not dry run).
Expand Down Expand Up @@ -710,7 +710,7 @@ func TestPostJiraStickyComment_DryRun_Create(t *testing.T) {
tc, _, err := tracker.NewFakeJiraClientWithFake("https://acme.atlassian.net")
require.NoError(t, err)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->", DryRun: true}
cfg := sticky.Config{Marker: "<!-- test -->", DryRun: true, KeepHistory: true}

url, err := postJiraStickyComment(context.Background(), tc, "PROJ", 42, "hello", cfg, printer)
require.NoError(t, err)
Expand All @@ -727,7 +727,7 @@ func TestPostJiraStickyComment_DryRun_Update(t *testing.T) {
tc, _, err := tracker.NewFakeJiraClientWithFake("https://acme.atlassian.net")
require.NoError(t, err)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->"}
cfg := sticky.Config{Marker: "<!-- test -->", KeepHistory: true}
ctx := context.Background()

// Create the initial comment (not dry run).
Expand All @@ -750,7 +750,7 @@ func TestPostJiraStickyComment_EmptyBody(t *testing.T) {
tc, _, err := tracker.NewFakeJiraClientWithFake("https://acme.atlassian.net")
require.NoError(t, err)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: "<!-- test -->"}
cfg := sticky.Config{Marker: "<!-- test -->", KeepHistory: true}

_, err = postJiraStickyComment(context.Background(), tc, "PROJ", 42, " ", cfg, printer)
require.Error(t, err)
Expand All @@ -761,7 +761,7 @@ func TestPostJiraStickyComment_EmptyMarker(t *testing.T) {
tc, _, err := tracker.NewFakeJiraClientWithFake("https://acme.atlassian.net")
require.NoError(t, err)
printer := ui.New(io.Discard)
cfg := sticky.Config{Marker: " "}
cfg := sticky.Config{Marker: " ", KeepHistory: true}

_, err = postJiraStickyComment(context.Background(), tc, "PROJ", 42, "body", cfg, printer)
require.Error(t, err)
Expand Down Expand Up @@ -858,6 +858,56 @@ func TestResolveTracker_FullsendDirWithoutTrackerSet_Errors(t *testing.T) {
assert.Contains(t, err.Error(), "--tracker is required")
}

// --- resolveKeepHistory tests ---
//
// Mirrors the resolveTracker test suite. resolveKeepHistory resolves
// the keep_history setting from: (1) explicit flag, (2) config
// reader, (3) fullsend-dir config.yaml, (4) default true.

func TestResolveKeepHistory_FlagOverridesConfig(t *testing.T) {
reader, err := config.ParsePerRepoConfig([]byte("keep_history: true\n"))
require.NoError(t, err)

flagVal := false
got, err := resolveKeepHistory(&flagVal, "", reader)
require.NoError(t, err)
assert.False(t, got, "explicit flag=false should override config=true")
}

func TestResolveKeepHistory_FallsBackToConfigReader(t *testing.T) {
reader, err := config.ParsePerRepoConfig([]byte("keep_history: false\n"))
require.NoError(t, err)

got, err := resolveKeepHistory(nil, "", reader)
require.NoError(t, err)
assert.False(t, got, "nil flag should fall back to config reader value")
}

func TestResolveKeepHistory_FallsBackToFullsendDirConfig(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("keep_history: false\n"), 0o644))

got, err := resolveKeepHistory(nil, dir, nil)
require.NoError(t, err)
assert.False(t, got, "nil flag + nil reader should fall back to fullsend-dir config")
}

func TestResolveKeepHistory_NilEverythingDefaultsTrue(t *testing.T) {
got, err := resolveKeepHistory(nil, "", nil)
require.NoError(t, err)
assert.True(t, got, "nil flag + no config + no fullsend-dir should default to true")
}

func TestResolveKeepHistory_ConfigLoadErrorReturnsTrueWithError(t *testing.T) {
// Point at a directory with an invalid config.yaml to trigger a load error.
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(":\tinvalid yaml\n"), 0o644))

got, err := resolveKeepHistory(nil, dir, nil)
require.Error(t, err, "should propagate config load error")
assert.True(t, got, "should default to true on config load error")
}

// --- config-default --tracker integration tests ---

func TestRunIssuesPostComment_TrackerFromConfig(t *testing.T) {
Expand Down
35 changes: 27 additions & 8 deletions internal/cli/postcomment.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import (

func newPostCommentCmd() *cobra.Command {
var (
repo string
number int
marker string
result string
token string
dryRun bool
repo string
number int
marker string
result string
token string
dryRun bool
keepHistory bool
fullsendDir string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -67,10 +69,25 @@ The --result flag accepts a file path or "-" for stdin.`,

printer.Header("Post Comment")

// Resolve keep_history: explicit --keep-history flag takes
// precedence, otherwise fall back to config.yaml via
// --fullsend-dir (matching the pattern in issues post-comment
// and post-review).
resolvedKeepHistory := keepHistory
if !cmd.Flags().Changed("keep-history") {
var khFlag *bool // nil = not explicitly set
resolved, resolveErr := resolveKeepHistory(khFlag, fullsendDir, nil)
if resolveErr != nil {
printer.StepWarn(fmt.Sprintf("Warning: %v; defaulting to keep_history=true", resolveErr))
}
resolvedKeepHistory = resolved
}

client := gh.New(token)
cfg := sticky.Config{
Marker: marker,
DryRun: dryRun,
Marker: marker,
DryRun: dryRun,
KeepHistory: resolvedKeepHistory,
}
_, err = sticky.Post(cmd.Context(), client, owner, repoName, number, body, cfg, printer)
return err
Expand All @@ -83,6 +100,8 @@ The --result flag accepts a file path or "-" for stdin.`,
cmd.Flags().StringVar(&result, "result", "-", "path to comment body file, or '-' for stdin")
Comment thread
ralphbean marked this conversation as resolved.
Comment thread
ralphbean marked this conversation as resolved.
Comment thread
ralphbean marked this conversation as resolved.
cmd.Flags().StringVar(&token, "token", "", "GitHub token (default: $GITHUB_TOKEN)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print what would be posted without making API calls")
cmd.Flags().BoolVar(&keepHistory, "keep-history", true, "append previous content as collapsed history blocks (set false to replace in-place)")
cmd.Flags().StringVar(&fullsendDir, "fullsend-dir", os.Getenv("FULLSEND_DIR"), "path to .fullsend config directory (default: $FULLSEND_DIR; sources defaults from its config.yaml when flags are omitted)")
_ = cmd.MarkFlagRequired("repo")
_ = cmd.MarkFlagRequired("number")
_ = cmd.MarkFlagRequired("marker")
Expand Down
Loading
Loading