Skip to content

feat(#849): add file-issue CLI command with dedup guard - #871

Open
fullsend-ai-coder[bot] wants to merge 2 commits into
mainfrom
agent/849-retro-dedup-guard
Open

feat(#849): add file-issue CLI command with dedup guard#871
fullsend-ai-coder[bot] wants to merge 2 commits into
mainfrom
agent/849-retro-dedup-guard

Conversation

@fullsend-ai-coder

Copy link
Copy Markdown

Summary

Adds a fullsend file-issue CLI command that wraps issue creation with a built-in dedup guard, preventing concurrent retro agent runs from filing duplicate issues. When multiple concurrent retro agents independently identify the same improvement, only one issue is created — the others detect the duplicate via title similarity search and skip filing.

Related Issue

Closes #849

Changes

  • Add SearchIssues method to the forge.Client interface with IssueSearchOptions (owner, repo, creator, since, state)
  • Implement SearchIssues for GitHub (via the Search API /search/issues), GitLab (stub returning ErrNotSupported), and FakeClient
  • Add fullsend file-issue CLI command with --repo, --title, --body, --label, --token, --creator, --dedup-window, --dry-run flags
  • Title similarity uses Jaccard word-overlap coefficient with stop-word filtering (threshold: 0.6)
  • JSON output: {created, url, number, duplicate_of}
  • Search failures are non-fatal — the command falls through to creation rather than blocking filing
  • When a duplicate is detected, a comment is added to the existing issue noting the additional evidence

Testing

  • Title similarity tests (identical, near-identical, rephrased, different, case-insensitive, edge cases)
  • normalizeWords tests (stop word removal, punctuation, number preservation)
  • Dedup integration tests: duplicate found, no duplicate, no creator, search failure fallthrough, dry run, distinct proposals pass, different repos never suppress
  • Comment failure on duplicate is non-fatal
  • GitHub SearchIssues tests: result parsing, PR exclusion, default state
  • go vet passes
  • go build ./... passes
  • make lint — pre-commit could not run in sandbox (network restricted); the post-script runs it authoritatively

Checklist

  • PR title follows Conventional Commits (correct type, ! for breaking changes)
  • Commits are signed off (DCO) — autonomous agent, exempt
  • Tests added for new logic

Closes #849

Post-script verification

  • Branch is not main/master (agent/849-retro-dedup-guard)
  • Secret scan passed (gitleaks — c887fc47c59d8b87b5282ca44044d2933d30df41..HEAD)
  • PR body secret scan passed (gitleaks — no-git)
  • Pre-commit hooks passed (authoritative run on runner)
  • Tests ran inside sandbox

Add a `fullsend file-issue` CLI command that wraps issue creation
with a built-in dedup guard, preventing concurrent retro agent
runs from filing duplicate issues. When 23 workspace-update PRs
were closed simultaneously on 2026-07-24, 5 retro agents
independently filed near-identical issues within a 2-minute window.

The command solves the TOCTOU race by searching for recent issues
with similar titles before creating new ones:

- Add `SearchIssues` to `forge.Client` interface with
  `IssueSearchOptions` (owner, repo, creator, since, state)
- Implement for GitHub (Search API), GitLab (stub), FakeClient
- Title similarity uses Jaccard word-overlap coefficient with
  stop-word filtering (threshold: 0.6)
- JSON output: `{created, url, number, duplicate_of}`
- Search failures are non-fatal (fall through to creation)
- Flags: --creator, --dedup-window, --dry-run

Note: pre-commit could not run in sandbox (network restricted);
the post-script runs it authoritatively on the runner.

Closes #849
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 9:48 AM UTC · Completed 10:03 AM UTC
Commit: 65e1af5 · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review

Findings

Medium

Low

  • [race-condition] internal/cli/fileissue.go:147 — The dedup guard uses a check-then-create pattern with no atomicity guarantee. Concurrent agents can all pass the search phase before any creates. This residual race is inherent to distributed dedup without server-side locking. The design trade-off is intentional and documented, but worth acknowledging explicitly since it is the exact scenario described in issue Retro post-script should deduplicate proposals against recently-filed bot issues before creating new ones #849.

  • [missing-test] internal/forge/github/github_comment_test.go — The containsSearchSyntax validation path in SearchIssues has no test coverage. If the validation were accidentally removed, no test would catch the regression. Add a TestSearchIssuesRejectsInvalidCreator test case.

  • [fail-open] internal/cli/fileissue.go:155 — Dedup guard intentionally fails open on search error (falls through to creation). This is documented in code and the dedup_skipped JSON field signals it to callers. Intentional design trade-off.

  • [edge-case] internal/cli/fileissue.go:225titlesSimilar uses strict > (not >=) for the 0.6 threshold, but the comment on defaultSimilarityThreshold says titles sharing "≥60%" are near-certain rephrasings. Minor doc/code mismatch; the test suite covers this boundary intentionally.

  • [output-destination-consistency] internal/cli/fileissue.go:66 — Uses ui.New(os.Stderr) correctly (JSON to stdout, status to stderr), but the codebase convention (only scan output also uses os.Stderr) includes a clarifying inline comment. Add one here.

  • [api-contract-completeness] internal/forge/forge.go:519SearchIssues uses an options-struct pattern while all other forge.Client methods pass owner, repo positionally. Consider SearchIssues(ctx, owner, repo string, opts IssueSearchOptions) for consistency.

  • [interface-method-grouping] internal/forge/forge.go:519 — Blank lines around SearchIssues break the contiguous grouping of issue methods in the interface definition.

  • [api-contract-documentation] internal/forge/forge.go:519 — Doc comment says "at most one page of results" without specifying page size. The GitHub implementation hard-codes per_page=100. Document the expected max result count.


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR
Previous run

Review

Findings

Medium

  • [api-contract] internal/forge/forge.go:190 — The IssueSearchOptions.State field doc comment states "open", "closed", or "" for any state, but the GitHub SearchIssues implementation defaults empty State to "open" (github.go:2577), meaning State="" returns only open issues rather than all states as the contract promises. No current caller passes empty State, but a future caller following the documented contract would silently get incorrect results.
    Remediation: Either update the doc comment to say empty defaults to "open", or change the implementation to omit the is:open/is:closed qualifier when State is empty.

Low

  • [edge-case] internal/cli/fileissue.go:284 — The stop word list includes "add", causing opposite-action titles like "Add retro dedup guard" and "Remove retro dedup guard" to compute as highly similar (Jaccard 3/4 = 0.75, above the 0.6 threshold) despite describing opposite operations. This could suppress a legitimate new issue as a duplicate.

  • [input-validation] internal/forge/github/github.go:2572SearchIssues interpolates Creator directly into the GitHub Search query via fmt.Sprintf("author:%s", ...) without sanitizing for spaces or search qualifiers. The CLI currently passes Creator from a local --creator flag (same trust boundary), but future callers passing untrusted input could inject additional search qualifiers.

  • [fail-open] internal/cli/fileissue.go:146 — When the dedup search fails, the code falls through to issue creation (intentional and tested). When --creator is omitted, dedup is skipped entirely with no indication in the JSON output. Consider adding a dedup_skipped field so callers can detect when the guard was not enforced.

  • [intent-alignment] internal/cli/fileissue.go:31 — The similarity threshold of 0.6 (Jaccard coefficient) is hardcoded without a comment explaining why this value was chosen over others.

  • [naming-convention] internal/cli/fileissue.go:120 — Comment describes the function as "exported-name-style (lowercase, unexported)" which is self-contradictory — exported names in Go are uppercase.


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR
Previous run (2)

Review

Findings

Medium

  • [scope-creep] internal/cli/fileissue.go — Issue Retro post-script should deduplicate proposals against recently-filed bot issues before creating new ones #849 describes fixing the retro post-script's filing pipeline, but this PR implements a general-purpose fullsend file-issue CLI command with 8 flags. While implementing as a CLI subcommand follows the repo's forge-abstraction guide (which recommends CLI subcommands over inline gh api calls), the broader-than-specified interface exceeds what the issue's validation criteria describe. Consider updating issue Retro post-script should deduplicate proposals against recently-filed bot issues before creating new ones #849 to explicitly authorize the CLI-command approach and demonstrating how the retro post-script will invoke it.

  • [forge-abstraction-violation] internal/cli/fileissue.go:128 — The command's RunE handler calls newGitHubLiveClient(token, "") directly, hard-coding GitHub as the only supported forge. While this matches the pattern in other CLI commands (post-comment, post-review), the fileIssueWithDedup function itself correctly accepts forge.Client, creating an inconsistency between the testable core (forge-agnostic) and the wiring (GitHub-only). Consider using a forge client factory so the command works across forges when GitLab/Forgejo support is added.

Low

  • [logic-error] internal/cli/fileissue.go:321 — The word "add" is in the stop-word list but other common action verbs (fix, remove, update, improve, delete, revert) are not. This means "Add empty-diff guard" vs "Fix empty-diff guard" yields Jaccard similarity 0.75, exceeding the 0.6 threshold. In the target use case (same-bot, 30-minute window), this is unlikely to cause false positives, but the inconsistency could surprise future callers.

  • [test-inadequate] internal/cli/fileissue_test.go — No test case covers the different-action-same-feature scenario (e.g., "Add X" vs "Fix X"). Adding such a case would document the expected behavior for this edge case.

  • [injection] internal/forge/github/github.go:2578SearchIssues interpolates opts.Owner, opts.Repo, opts.Creator, and opts.State into the GitHub Search API q parameter without validating for spaces or search syntax characters. In CLI context the risk is low (operator-supplied arguments, token-scoped results), but an allowlist for State ("open"/"closed") and character validation for identifier fields would harden the API.

  • [edge-case] internal/cli/fileissue.go:263titleSimilarity returns 1.0 when both inputs normalize to empty word sets. The union == 0 guard at line 286 is unreachable (the len(wordsA)==0 && len(wordsB)==0 check at line 262 returns first). Dead code, harmless but unnecessary.

  • [fail-open] internal/cli/fileissue.go:187 — Search failure falls through to issue creation (intentional, documented). Callers relying on dedup for correctness guarantees should be aware it is best-effort.

  • [architectural-coherence] internal/cli/fileissue.go — Issue Retro post-script should deduplicate proposals against recently-filed bot issues before creating new ones #849 frames this as a "filing-layer fix that complements the agent-side semantic dedup proposed in Retro agent should deduplicate proposals across related PRs (backports, cherry-picks) fullsend-ai/fullsend#3426 and Deduplicate retro analysis across cross-branch sibling PRs with identical diffs fullsend-ai/fullsend#4571." Clarifying which layer owns dedup responsibility in a design comment would help future contributors.

  • [intent-alignment] internal/cli/fileissue.go — The integration path (retro post-script calling fullsend file-issue instead of gh issue create) is not demonstrated in this PR. A follow-up PR should wire the post-script to use this command.

  • [stale-reference] docs/superpowers/specs/2026-05-04-retro-agent-design.md:154 — References gh issue create for filing issues. Will become stale when a follow-up PR switches the retro post-script to use fullsend file-issue.

  • [documentation-clarity] internal/cli/fileissue.go:158 — Comment says "exported-name-style (lowercase, unexported)" which contradicts itself. Simplify to "unexported but testable via the forge.Client interface."

  • [naming-alignment] docs/cli/README.md — "dedup guard" terminology is not established in project vocabulary, though issue Retro post-script should deduplicate proposals against recently-filed bot issues before creating new ones #849 uses the phrase.


Labels: PR adds a new file-issue CLI command implementing a dedup guard feature

@fullsend-ai-review fullsend-ai-review Bot added requires-manual-review Review requires human judgment feature labels Aug 2, 2026
@guyoron1

guyoron1 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

/fs-review

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 10:31 AM UTC · Completed 10:49 AM UTC
Commit: 65e1af5 · View workflow run →

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot removed the requires-manual-review Review requires human judgment label Aug 2, 2026
@guyoron1

guyoron1 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

/fs-fix

@fullsend-ai-coder

fullsend-ai-coder Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

🤖 Finished Fix · ✅ Success · Started 10:51 AM UTC · Completed 11:00 AM UTC
Commit: 65e1af5 · View workflow run →

- Fix API contract: update IssueSearchOptions.State doc to reflect that
  empty defaults to "open" (not "any state")
- Fix edge case: remove "add" from stop-word list so action verbs
  contribute to similarity scoring; opposite-action titles (e.g.,
  "Add X" vs "Remove X") are no longer falsely matched as duplicates
- Change >= to > for threshold comparison to avoid boundary false
  positives at exactly 0.6
- Add input validation: reject Creator values containing spaces, colons,
  or quotes that could inject search qualifiers
- Add dedup_skipped field to FileIssueResult JSON output so callers can
  detect when the dedup guard was not enforced
- Add explanatory comment for the 0.6 similarity threshold
- Fix self-contradictory comment on fileIssueWithDedup
- Add test for opposite-action edge case and dedup_skipped assertions

Addresses review feedback on #871
@fullsend-ai-coder

Copy link
Copy Markdown
Author

🔧 Fix agent — iteration 1 (human-triggered)

Addressed all 6 review findings: fixed API contract doc, removed action verb from stop words and tightened threshold comparison, added Creator input validation, added dedup_skipped output field, documented threshold rationale, and fixed contradictory comment.

Fixed (6):

  1. API contract mismatch: State doc says empty means any state but impl defaults to open (internal/forge/forge.go): Updated IssueSearchOptions.State doc comment to say empty defaults to "open", matching the GitHub implementation
  2. Stop word list includes add, causing opposite-action titles to falsely match as duplicates (internal/cli/fileissue.go): Removed "add" from stop-word list so action verbs contribute to similarity scoring. Changed >= to > for threshold comparison to avoid boundary false positives. Added test for opposite-action edge case
  3. SearchIssues interpolates Creator without sanitizing for search qualifiers (internal/forge/github/github.go): Added containsSearchSyntax validation that rejects Creator values containing spaces, tabs, colons, or quotes that could inject additional search qualifiers
  4. No JSON indication when dedup is skipped (internal/cli/fileissue.go): Added dedup_skipped field to FileIssueResult (omitempty), set to true when creator is empty or search fails. Added test assertions for both cases
  5. Similarity threshold 0.6 hardcoded without explanation (internal/cli/fileissue.go): Added comment explaining that 0.6 balances precision vs recall for the same-bot short-window use case
  6. Self-contradictory comment: exported-name-style (lowercase, unexported) (internal/cli/fileissue.go): Rewrote comment to: unexported but testable via the forge.Client interface

Tests: passed

Next steps:

  • /fs-review — request a re-review of the changes
  • /fs-fix <your instruction> — run another fix pass with specific guidance
  • Push commits directly — review re-runs automatically on push
    Updated by fullsend fix agent

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 11:03 AM UTC · Completed 11:23 AM UTC
Commit: 9f3ad48 · View workflow run →

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

}

// Build the search query string per GitHub Search syntax.
parts := []string{fmt.Sprintf("repo:%s/%s", opts.Owner, opts.Repo), "is:issue"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] injection

SearchIssues validates opts.Creator with containsSearchSyntax() but does not validate opts.Owner, opts.Repo, or opts.State before interpolating them into the GitHub Search query string. While the current CLI call site constrains these values, the method is on the forge.Client interface and may be called by future code with unsanitized inputs.

Suggested fix: Apply containsSearchSyntax() validation to opts.Owner, opts.Repo, and opts.State. Validate State against an allowlist of 'open'/'closed'.

Comment thread internal/cli/fileissue.go

since := time.Now().UTC().Add(-dedupWindow)
existing, err := client.SearchIssues(ctx, forge.IssueSearchOptions{
Owner: owner,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] race-condition

TOCTOU race condition in dedup guard. The check-then-create pattern has no atomicity guarantee. Concurrent agents can all pass the search phase before any creates. This residual race is inherent to distributed dedup without server-side locking. The design trade-off is intentional.

Comment thread internal/cli/fileissue.go
})
if err != nil {
// Non-fatal: if the search fails (e.g., rate limit, unsupported
// forge), fall through to creation rather than blocking filing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] fail-open

Dedup guard intentionally fails open on search error (falls through to creation). Documented in code and signaled via dedup_skipped JSON field. Intentional design trade-off.

Comment thread internal/cli/fileissue.go
// stripping punctuation. A coefficient strictly above
// defaultSimilarityThreshold is considered a match.
func titlesSimilar(a, b string) bool {
return titleSimilarity(a, b) > defaultSimilarityThreshold

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

titlesSimilar uses strict > (not >=) for the 0.6 threshold, but the comment on defaultSimilarityThreshold says titles sharing >=60% are near-certain rephrasings. Minor doc/code mismatch; the test suite covers this boundary intentionally.

Comment thread internal/cli/fileissue.go

Output is JSON with fields: created, url, number, duplicate_of.`,
RunE: func(cmd *cobra.Command, args []string) error {
printer := ui.New(os.Stderr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] output-destination-consistency

Uses ui.New(os.Stderr) correctly (JSON to stdout, status to stderr), but the codebase convention includes a clarifying inline comment when using os.Stderr.

Suggested fix: Add comment: // status to stderr, JSON output to stdout

Comment thread internal/forge/forge.go
// SearchIssues queries for issues matching an IssueSearchOptions filter.
// Used for dedup checks before filing (e.g., retro proposals). Returns
// at most one page of results sorted by creation time descending.
SearchIssues(ctx context.Context, opts IssueSearchOptions) ([]Issue, error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] api-contract-completeness

SearchIssues uses an options-struct pattern while all other forge.Client methods pass owner, repo positionally. This creates an API style inconsistency.

Suggested fix: Consider SearchIssues(ctx, owner, repo string, opts IssueSearchOptions) to keep Owner/Repo positional.

Comment thread internal/forge/forge.go
// SearchIssues queries for issues matching an IssueSearchOptions filter.
// Used for dedup checks before filing (e.g., retro proposals). Returns
// at most one page of results sorted by creation time descending.
SearchIssues(ctx context.Context, opts IssueSearchOptions) ([]Issue, error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] interface-method-grouping

Blank lines around SearchIssues break the contiguous grouping of issue methods in the interface definition.

Suggested fix: Remove blank lines before and after SearchIssues so it sits contiguously with other issue methods.

Comment thread internal/forge/forge.go
// SearchIssues queries for issues matching an IssueSearchOptions filter.
// Used for dedup checks before filing (e.g., retro proposals). Returns
// at most one page of results sorted by creation time descending.
SearchIssues(ctx context.Context, opts IssueSearchOptions) ([]Issue, error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] api-contract-documentation

Doc comment says 'at most one page of results' without specifying page size. The GitHub implementation hard-codes per_page=100. Callers cannot know the max result count from the interface alone.

Suggested fix: Document 'at most 100 results' in the interface doc comment.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had any activity in the last month. It will be closed in 2 weeks if no further activity occurs. Remove the stale label to reset the inactivity timer.

@github-actions github-actions Bot added the stale label Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retro post-script should deduplicate proposals against recently-filed bot issues before creating new ones

1 participant