Skip to content

Commit 46cd0f1

Browse files
authored
Merge branch 'main' into chore/dco-enforcement
2 parents b69838d + e09ca0f commit 46cd0f1

8 files changed

Lines changed: 548 additions & 0 deletions

File tree

.github/workflows/auto-close.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# =============================================================================
2+
# Auto-Close Duplicate Issues
3+
# =============================================================================
4+
# Runs daily at 10:00 UTC to close issues whose grace period has expired.
5+
# Also supports manual trigger via workflow_dispatch.
6+
#
7+
# Required secrets:
8+
# GH_PAT – Token with issues:write permission
9+
# =============================================================================
10+
11+
name: Auto-Close Duplicates
12+
13+
on:
14+
schedule:
15+
# Daily at 10:00 UTC
16+
- cron: '0 10 * * *'
17+
workflow_dispatch:
18+
inputs:
19+
dry_run:
20+
description: 'Run in dry-run mode (no side effects)'
21+
required: false
22+
default: 'false'
23+
type: choice
24+
options:
25+
- 'false'
26+
- 'true'
27+
28+
permissions:
29+
contents: read
30+
issues: write
31+
32+
jobs:
33+
auto-close:
34+
name: Auto-Close Expired Duplicates
35+
runs-on: ubuntu-latest
36+
steps:
37+
- name: Checkout
38+
uses: actions/checkout@v4
39+
40+
- name: Set up Go
41+
uses: actions/setup-go@v5
42+
with:
43+
go-version: '1.23'
44+
45+
- name: Build CLI
46+
run: go build -o simili-cli ./cmd/simili/main.go
47+
48+
- name: Run auto-close
49+
env:
50+
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
51+
run: |
52+
DRY_RUN_FLAG=""
53+
if [ "${{ inputs.dry_run }}" = "true" ]; then
54+
DRY_RUN_FLAG="--dry-run"
55+
fi
56+
57+
./simili-cli auto-close \
58+
--repo "${{ github.repository }}" \
59+
--config .github/simili.yaml \
60+
--verbose \
61+
$DRY_RUN_FLAG

internal/core/config/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,22 @@ type Config struct {
4444
// Transfer configures cross-repository issue routing.
4545
Transfer TransferConfig `yaml:"transfer,omitempty"`
4646

47+
// AutoClose configures the automatic closure of confirmed duplicate issues.
48+
AutoClose AutoCloseConfig `yaml:"auto_close,omitempty"`
49+
4750
// BotUsers is a list of GitHub usernames whose events should be ignored
4851
// to prevent infinite comment loops. Built-in heuristics (e.g. "[bot]" suffix,
4952
// "gh-simili" prefix) always apply in addition to this list.
5053
BotUsers []string `yaml:"bot_users,omitempty"`
5154
}
5255

56+
// AutoCloseConfig configures the auto-close behavior for duplicate issues.
57+
type AutoCloseConfig struct {
58+
GracePeriodHours int `yaml:"grace_period_hours"` // Hours after labeling before auto-close (default: 72)
59+
GracePeriodMinutesOverride int `yaml:"-"` // CLI-only override in minutes (for testing; 0 = use GracePeriodHours)
60+
DryRun bool `yaml:"dry_run,omitempty"` // If true, log actions without executing
61+
}
62+
5363
// QdrantConfig holds Qdrant connection settings.
5464
type QdrantConfig struct {
5565
URL string `yaml:"url"`
@@ -286,6 +296,10 @@ func (c *Config) applyDefaults() {
286296
if c.Transfer.RepoCollection == "" {
287297
c.Transfer.RepoCollection = "simili_repos"
288298
}
299+
// Auto-close defaults
300+
if c.AutoClose.GracePeriodHours == 0 {
301+
c.AutoClose.GracePeriodHours = 72
302+
}
289303
}
290304

291305
// mergeConfigs merges a child config onto a parent config.
@@ -376,6 +390,14 @@ func mergeConfigs(parent, child *Config) *Config {
376390
result.Transfer.RepoCollection = child.Transfer.RepoCollection
377391
}
378392

393+
// AutoClose: override if fields are set
394+
if child.AutoClose.GracePeriodHours != 0 {
395+
result.AutoClose.GracePeriodHours = child.AutoClose.GracePeriodHours
396+
}
397+
if child.AutoClose.DryRun {
398+
result.AutoClose.DryRun = child.AutoClose.DryRun
399+
}
400+
379401
return &result
380402
}
381403

internal/integrations/github/client.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,36 @@ func (c *Client) AddLabels(ctx context.Context, org, repo string, number int, la
5858
return nil
5959
}
6060

61+
// CloseIssue closes a GitHub issue by setting its state to "closed".
62+
func (c *Client) CloseIssue(ctx context.Context, org, repo string, number int) error {
63+
closed := "closed"
64+
_, _, err := c.client.Issues.Edit(ctx, org, repo, number, &github.IssueRequest{
65+
State: &closed,
66+
})
67+
if err != nil {
68+
return fmt.Errorf("failed to close issue #%d: %w", number, err)
69+
}
70+
return nil
71+
}
72+
73+
// RemoveLabel removes a single label from an issue.
74+
// Returns nil if the label was not present (GitHub API returns 404, which we treat as success).
75+
func (c *Client) RemoveLabel(ctx context.Context, org, repo string, number int, label string) error {
76+
if strings.TrimSpace(label) == "" {
77+
return fmt.Errorf("label cannot be empty")
78+
}
79+
80+
_, err := c.client.Issues.RemoveLabelForIssue(ctx, org, repo, number, label)
81+
if err != nil {
82+
// If the label wasn't on the issue, GitHub returns 404 — not an error for us.
83+
if ghErr, ok := err.(*github.ErrorResponse); ok && ghErr.Response.StatusCode == 404 {
84+
return nil
85+
}
86+
return fmt.Errorf("failed to remove label %q from issue #%d: %w", label, number, err)
87+
}
88+
return nil
89+
}
90+
6191
// TransferIssue transfers an issue to another repository using GitHub GraphQL API.
6292
// Requires the user to have admin/write access to both repositories.
6393
// targetRepo should be in "owner/repo" format.

internal/integrations/github/client_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,17 @@ func TestTransferIssueValidation(t *testing.T) {
6969
})
7070
}
7171
}
72+
73+
func TestRemoveLabelValidation(t *testing.T) {
74+
client := &Client{client: nil}
75+
76+
err := client.RemoveLabel(context.Background(), "org", "repo", 1, "")
77+
if err == nil {
78+
t.Error("Expected error for empty label")
79+
}
80+
81+
err = client.RemoveLabel(context.Background(), "org", "repo", 1, " ")
82+
if err == nil {
83+
t.Error("Expected error for whitespace-only label")
84+
}
85+
}

0 commit comments

Comments
 (0)