Skip to content

Commit 53abf8b

Browse files
KavirubcCopilot
andauthored
feat: Implement Transfer Rules Engine for Cross-Repository Issue Routing (#22)
* feat: Create new development session and set as current. * feat: Implement GitHub issue transfer functionality with rule-based matching and GraphQL API integration. * feat: Enhance `TransferIssue` to return the new issue URL and improve GraphQL client validation, which is then used by the action executor. * Update internal/transfer/matcher.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: address code review feedback - Add whitespace trimming for targetRepo input validation - Truncate error response bodies to prevent sensitive data leaks - Add empty URL validation after transfer completion - Fix Transfer config merge logic to allow child to disable parent --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 97aa0ec commit 53abf8b

14 files changed

Lines changed: 850 additions & 29 deletions

File tree

.claude/sessions/.current-session

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2026-02-04-0000.md
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Development Session - 2026-02-04 00:00
2+
3+
## Session Overview
4+
- **Start Time**: 2026-02-04 00:00
5+
- **Status**: Active
6+
7+
## Goals
8+
- Develop and implement new features for simili-bot
9+
10+
## Progress
11+
12+
### Tasks Completed
13+
- Session started
14+
15+
### Current Work
16+
- Ready to begin feature development
17+
18+
### Notes
19+
-
20+

internal/core/config/config.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type Config struct {
3737

3838
// Repositories lists the repositories this config applies to.
3939
Repositories []RepositoryConfig `yaml:"repositories,omitempty"`
40+
41+
// Transfer configures cross-repository issue routing.
42+
Transfer TransferConfig `yaml:"transfer,omitempty"`
4043
}
4144

4245
// QdrantConfig holds Qdrant connection settings.
@@ -69,6 +72,25 @@ type RepositoryConfig struct {
6972
Enabled bool `yaml:"enabled"`
7073
}
7174

75+
// TransferRule defines a rule for transferring issues to another repository.
76+
type TransferRule struct {
77+
Name string `yaml:"name"`
78+
Priority int `yaml:"priority,omitempty"`
79+
Target string `yaml:"target"` // "owner/repo"
80+
Labels []string `yaml:"labels,omitempty"` // ALL must match
81+
LabelsAny []string `yaml:"labels_any,omitempty"` // ANY must match
82+
TitleContains []string `yaml:"title_contains,omitempty"`
83+
BodyContains []string `yaml:"body_contains,omitempty"`
84+
Author []string `yaml:"author,omitempty"`
85+
Enabled *bool `yaml:"enabled,omitempty"`
86+
}
87+
88+
// TransferConfig holds transfer routing settings.
89+
type TransferConfig struct {
90+
Enabled bool `yaml:"enabled"`
91+
Rules []TransferRule `yaml:"rules,omitempty"`
92+
}
93+
7294
// Load reads a config file from the given path and expands environment variables.
7395
func Load(path string) (*Config, error) {
7496
data, err := os.ReadFile(path)
@@ -214,6 +236,13 @@ func mergeConfigs(parent, child *Config) *Config {
214236
result.Repositories = child.Repositories
215237
}
216238

239+
// Transfer.Enabled: always take the child value so it can override parent true -> false and vice versa
240+
result.Transfer.Enabled = child.Transfer.Enabled
241+
// Transfer.Rules: child overrides rules if non-empty; otherwise inherit from parent
242+
if len(child.Transfer.Rules) > 0 {
243+
result.Transfer.Rules = child.Transfer.Rules
244+
}
245+
217246
return &result
218247
}
219248

internal/core/pipeline/registry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ var Presets = map[string][]string{
113113
"issue-triage": {
114114
"gatekeeper",
115115
"vectordb_prep",
116-
"similarity_search",
117116
"transfer_check",
117+
"similarity_search",
118118
"triage",
119119
"response_builder",
120120
"action_executor",

internal/integrations/github/auth.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,21 @@ import (
1717
// If token is empty, it returns an unauthenticated client.
1818
func NewClient(ctx context.Context, token string) *Client {
1919
var tc *http.Client
20+
var graphql *GraphQLClient
2021

2122
if token != "" {
2223
ts := oauth2.StaticTokenSource(
2324
&oauth2.Token{AccessToken: token},
2425
)
2526
tc = oauth2.NewClient(ctx, ts)
27+
// Initialize GraphQL client for authenticated operations
28+
graphql = NewGraphQLClient(tc, token)
2629
}
2730

2831
client := github.NewClient(tc)
2932

3033
return &Client{
31-
client: client,
34+
client: client,
35+
graphql: graphql,
3236
}
3337
}

internal/integrations/github/client.go

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import (
1515

1616
// Client wraps the GitHub API client.
1717
type Client struct {
18-
client *github.Client
18+
client *github.Client
19+
graphql *GraphQLClient
1920
}
2021

2122
// GetIssue fetches issue details.
@@ -57,25 +58,49 @@ func (c *Client) AddLabels(ctx context.Context, org, repo string, number int, la
5758
return nil
5859
}
5960

60-
// TransferIssue transfers an issue to another repository.
61-
// Note: Transferring issues via API requires the user to have admin access.
61+
// TransferIssue transfers an issue to another repository using GitHub GraphQL API.
62+
// Requires the user to have admin/write access to both repositories.
6263
// targetRepo should be in "owner/repo" format.
63-
//
64-
// TODO: GitHub's REST API for issue transfers is complex and may require GraphQL.
65-
// This is not yet implemented. See https://docs.github.com/en/graphql/reference/mutations#transferissue
66-
func (c *Client) TransferIssue(ctx context.Context, org, repo string, number int, targetRepo string) error {
64+
// Returns the URL of the transferred issue.
65+
func (c *Client) TransferIssue(ctx context.Context, org, repo string, number int, targetRepo string) (string, error) {
66+
// Trim whitespace from input
67+
targetRepo = strings.TrimSpace(targetRepo)
68+
6769
// Validate input format
6870
parts := strings.Split(targetRepo, "/")
6971
if len(parts) != 2 {
70-
return fmt.Errorf("invalid targetRepo format: expected 'owner/repo', got '%s'", targetRepo)
72+
return "", fmt.Errorf("invalid targetRepo format: expected 'owner/repo', got '%s'", targetRepo)
73+
}
74+
75+
targetOwner, targetRepoName := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
76+
if targetOwner == "" || targetRepoName == "" {
77+
return "", fmt.Errorf("invalid targetRepo: owner and repo cannot be empty")
78+
}
79+
80+
// Check if GraphQL client is available
81+
if c.graphql == nil {
82+
return "", fmt.Errorf("issue transfer requires authenticated GraphQL client")
83+
}
84+
85+
// Get issue node ID
86+
issueNodeID, err := c.graphql.GetIssueNodeID(ctx, org, repo, number)
87+
if err != nil {
88+
return "", fmt.Errorf("failed to get issue node ID: %w", err)
89+
}
90+
91+
// Get target repository node ID
92+
targetRepoNodeID, err := c.graphql.GetRepositoryNodeID(ctx, targetOwner, targetRepoName)
93+
if err != nil {
94+
return "", fmt.Errorf("failed to get target repository node ID: %w", err)
7195
}
7296

73-
if parts[0] == "" || parts[1] == "" {
74-
return fmt.Errorf("invalid targetRepo: owner and repo cannot be empty")
97+
// Execute transfer
98+
newURL, err := c.graphql.TransferIssue(ctx, issueNodeID, targetRepoNodeID)
99+
if err != nil {
100+
return "", fmt.Errorf("failed to transfer issue: %w", err)
75101
}
76102

77-
// Transfer API is not implemented yet
78-
return fmt.Errorf("issue transfer not yet implemented - requires GraphQL API integration")
103+
return newURL, nil
79104
}
80105

81106
// ListIssues fetches a list of issues from the repository.

internal/integrations/github/client_test.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func TestAddLabelsValidation(t *testing.T) {
4141
}
4242

4343
func TestTransferIssueValidation(t *testing.T) {
44-
client := &Client{client: nil} // nil client for validation testing
44+
client := &Client{client: nil, graphql: nil} // nil client for validation testing
4545

4646
tests := []struct {
4747
name string
@@ -58,14 +58,13 @@ func TestTransferIssueValidation(t *testing.T) {
5858

5959
for _, tt := range tests {
6060
t.Run(tt.name, func(t *testing.T) {
61-
err := client.TransferIssue(context.Background(), "org", "repo", 1, tt.targetRepo)
61+
_, err := client.TransferIssue(context.Background(), "org", "repo", 1, tt.targetRepo)
6262
if tt.shouldFail && err == nil {
6363
t.Errorf("Expected error for targetRepo=%q", tt.targetRepo)
6464
}
65-
// Note: All cases should fail with "not yet implemented" but we're testing
66-
// that validation errors come first for invalid formats
67-
if !tt.shouldFail && err != nil && err.Error() != "issue transfer not yet implemented - requires GraphQL API integration" {
68-
t.Errorf("Expected 'not yet implemented' error, got: %v", err)
65+
// Valid format but no graphql client should fail with "requires authenticated GraphQL client"
66+
if !tt.shouldFail && err != nil && err.Error() != "issue transfer requires authenticated GraphQL client" {
67+
t.Errorf("Expected 'requires authenticated GraphQL client' error, got: %v", err)
6968
}
7069
})
7170
}

0 commit comments

Comments
 (0)