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
20 changes: 18 additions & 2 deletions .github/workflows/auto-close.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ on:
options:
- 'false'
- 'true'
grace_period_minutes:
description: 'Grace period in minutes before a potential-duplicate issue is labelled duplicate and closed (overrides config). Leave empty to use the configured default.'
required: false
default: ''
type: string

permissions:
contents: read
Expand All @@ -47,15 +52,26 @@ jobs:

- name: Run auto-close
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
GITHUB_TOKEN: ${{ github.token }}
GRACE_PERIOD_INPUT: ${{ inputs.grace_period_minutes }}
run: |
DRY_RUN_FLAG=""
if [ "${{ inputs.dry_run }}" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
fi

GRACE_ARGS=()
if [[ -n "$GRACE_PERIOD_INPUT" ]]; then
if [[ "$GRACE_PERIOD_INPUT" =~ ^[0-9]+$ ]]; then
GRACE_ARGS=("--grace-period-minutes" "$GRACE_PERIOD_INPUT")
else
echo "::warning::grace_period_minutes must be a positive integer; ignoring value '$GRACE_PERIOD_INPUT'"
fi
fi

./simili-cli auto-close \
--repo "${{ github.repository }}" \
--config .github/simili.yaml \
--verbose \
$DRY_RUN_FLAG
$DRY_RUN_FLAG \
"${GRACE_ARGS[@]}"
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,50 @@ Notes:
- `llm.api_key` can be omitted if `GEMINI_API_KEY` is set.
- You can override the model at runtime with `LLM_MODEL`.

### `simili auto-close`

Scan all open issues labelled `potential-duplicate` and close those whose grace period has expired with no human activity. Closed issues are relabelled from `potential-duplicate` → `duplicate`.

```bash
simili auto-close --repo owner/repo --grace-period-minutes 60
```

**Flags:**
- `--repo` (required): Target repository (`owner/name`); falls back to `GITHUB_REPOSITORY` env var
- `--grace-period-minutes`: Override the grace period in minutes for this run (see precedence below)
- `--dry-run`: Print what would be closed without making any changes
- `--config`: Path to `simili.yaml` (auto-discovered if omitted)

**Grace period precedence** (highest → lowest):

| Source | How to set |
|--------|-----------|
| `--grace-period-minutes` CLI flag | Pass at runtime — overrides everything |
| `auto_close.grace_period_hours` in `simili.yaml` | Persistent per-repo config |
| Built-in default | 72 hours (3 days) |

**`simili.yaml` configuration:**

```yaml
auto_close:
grace_period_hours: 48 # default: 72
dry_run: false
```

**Human activity signals** — any of these prevent auto-close:
1. A negative reaction (👎 or 😕) on the bot's triage comment by a non-bot user.
2. The issue was reopened by a human after the `potential-duplicate` label was applied.
3. A non-bot comment posted after the label was applied.

**GitHub Actions usage** — the `auto-close.yml` workflow runs daily at 10:00 UTC and can be triggered manually via `workflow_dispatch` with an optional `grace_period_minutes` input:

```yaml
# Trigger from GitHub UI or gh CLI:
gh workflow run auto-close.yml -f grace_period_minutes=60 -f dry_run=false
```

Leaving `grace_period_minutes` empty uses the value from `simili.yaml` (or the 72 h default).

## Development

```bash
Expand Down
144 changes: 144 additions & 0 deletions cmd/simili/commands/auto_close_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package commands

import (
"strings"
"testing"

"github.com/similigh/simili-bot/internal/core/config"
)

// TestGracePeriodMinutesCLIMapping validates the flag-to-config mapping logic:
// - An explicit 0 is stored as 1 (smallest positive value, triggers instant expiry in tests).
// - Any positive value is stored as-is.
// - When the flag was not set, GracePeriodMinutesOverride remains 0 (no override).
func TestGracePeriodMinutesCLIMapping(t *testing.T) {
tests := []struct {
name string
flagChanged bool
flagValue int
wantOverride int
}{
{
name: "flag not provided - no override",
flagChanged: false,
flagValue: 0,
wantOverride: 0,
},
{
name: "flag set to 0 - stored as 1 (instant expire)",
flagChanged: true,
flagValue: 0,
wantOverride: 1,
},
{
name: "flag set to negative - stored as 1 (instant expire)",
flagChanged: true,
flagValue: -5,
wantOverride: 1,
},
{
name: "flag set to 1 - stored as 1",
flagChanged: true,
flagValue: 1,
wantOverride: 1,
},
{
name: "flag set to 30 - stored as 30",
flagChanged: true,
flagValue: 30,
wantOverride: 30,
},
{
name: "flag set to 1440 (1 day in minutes) - stored as 1440",
flagChanged: true,
flagValue: 1440,
wantOverride: 1440,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{}

// Mirrors the mapping logic in runAutoClose.
if tt.flagChanged {
if tt.flagValue <= 0 {
cfg.AutoClose.GracePeriodMinutesOverride = 1
} else {
cfg.AutoClose.GracePeriodMinutesOverride = tt.flagValue
}
}

if cfg.AutoClose.GracePeriodMinutesOverride != tt.wantOverride {
t.Errorf("GracePeriodMinutesOverride = %d, want %d",
cfg.AutoClose.GracePeriodMinutesOverride, tt.wantOverride)
}
})
}
}

// TestRepoFlagParsing validates owner/repo splitting used in runAutoClose.
func TestRepoFlagParsing(t *testing.T) {
tests := []struct {
name string
input string
wantOrg string
wantRepo string
wantValid bool
}{
{
name: "valid org/repo",
input: "acme-corp/my-service",
wantOrg: "acme-corp",
wantRepo: "my-service",
wantValid: true,
},
{
name: "valid single-word org and repo",
input: "owner/repo",
wantOrg: "owner",
wantRepo: "repo",
wantValid: true,
},
{
name: "missing slash - invalid",
input: "ownerrepo",
wantValid: false,
},
{
name: "empty org - invalid",
input: "/repo",
wantValid: false,
},
{
name: "empty repo - invalid",
input: "owner/",
wantValid: false,
},
{
name: "empty string - invalid",
input: "",
wantValid: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
org, repo, ok := strings.Cut(tt.input, "/")
valid := ok && org != "" && repo != ""

if valid != tt.wantValid {
t.Errorf("repo %q: valid=%v, want %v", tt.input, valid, tt.wantValid)
return
}
if tt.wantValid {
if org != tt.wantOrg {
t.Errorf("org = %q, want %q", org, tt.wantOrg)
}
if repo != tt.wantRepo {
t.Errorf("repo = %q, want %q", repo, tt.wantRepo)
}
}
})
}
}
111 changes: 111 additions & 0 deletions internal/steps/auto_closer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,117 @@ func TestReopenedByHuman(t *testing.T) {
}
}

func TestGracePeriodFromConfig(t *testing.T) {
tests := []struct {
name string
minutesOverride int
hoursConfig int
wantDuration time.Duration
}{
{
name: "minutes override takes precedence over hours config",
minutesOverride: 5,
hoursConfig: 24,
wantDuration: 5 * time.Minute,
},
{
name: "minutes override of 1 takes precedence over default",
minutesOverride: 1,
hoursConfig: 0,
wantDuration: 1 * time.Minute,
},
{
name: "hours config used when no minutes override",
minutesOverride: 0,
hoursConfig: 48,
wantDuration: 48 * time.Hour,
},
{
name: "72-hour default applied when both are zero",
minutesOverride: 0,
hoursConfig: 0,
wantDuration: 72 * time.Hour,
},
{
name: "hours config of 1 is respected (no default override)",
minutesOverride: 0,
hoursConfig: 1,
wantDuration: 1 * time.Hour,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got time.Duration
if tt.minutesOverride > 0 {
got = time.Duration(tt.minutesOverride) * time.Minute
} else {
h := tt.hoursConfig
if h <= 0 {
h = 72
}
got = time.Duration(h) * time.Hour
}
if got != tt.wantDuration {
t.Errorf("grace period = %v, want %v", got, tt.wantDuration)
}
})
}
}

func TestGracePeriodMinutesExpiry(t *testing.T) {
tests := []struct {
name string
minutesOverride int
labeledAgo time.Duration
wantExpired bool
}{
{
name: "1-minute override: labeled 2 minutes ago - expired",
minutesOverride: 1,
labeledAgo: 2 * time.Minute,
wantExpired: true,
},
{
name: "1-minute override: labeled 30 seconds ago - not expired",
minutesOverride: 1,
labeledAgo: 30 * time.Second,
wantExpired: false,
},
{
name: "5-minute override: labeled 4 minutes ago - not expired",
minutesOverride: 5,
labeledAgo: 4 * time.Minute,
wantExpired: false,
},
{
name: "5-minute override: labeled 6 minutes ago - expired",
minutesOverride: 5,
labeledAgo: 6 * time.Minute,
wantExpired: true,
},
{
name: "60-minute override: labeled 59 minutes ago - not expired",
minutesOverride: 60,
labeledAgo: 59 * time.Minute,
wantExpired: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gracePeriod := time.Duration(tt.minutesOverride) * time.Minute
labeledAt := time.Now().Add(-tt.labeledAgo)
elapsed := time.Since(labeledAt)
got := elapsed >= gracePeriod
if got != tt.wantExpired {
t.Errorf("expiry check: grace=%v, elapsed≈%v => expired=%v, want %v",
gracePeriod, tt.labeledAgo, got, tt.wantExpired)
}
})
}
}

func TestAutoCloseResultCounts(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading