feat: link tasks to github references (#1471)

This commit is contained in:
Carlos Florêncio
2026-06-23 17:28:52 +01:00
committed by GitHub
parent 9b00a16bbe
commit 4d19fb32f1
34 changed files with 2367 additions and 120 deletions
@@ -41,6 +41,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.GET("/task-prs", c.httpListTaskPRs)
api.POST("/task-prs", c.httpCreateTaskPR)
api.GET("/task-prs/:taskId", c.httpGetTaskPR)
api.PUT("/tasks/:taskId/issue", c.httpLinkTaskIssue)
api.DELETE("/tasks/:taskId/issue", c.httpUnlinkTaskIssue)
api.GET("/tasks/:taskId/ci-options", c.httpGetTaskCIOptions)
api.PATCH("/tasks/:taskId/ci-options", c.httpPatchTaskCIOptions)
@@ -201,6 +203,62 @@ func (c *Controller) httpGetTaskPR(ctx *gin.Context) {
ctx.JSON(http.StatusOK, tp)
}
func (c *Controller) httpLinkTaskIssue(ctx *gin.Context) {
var req LinkTaskIssueRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := c.service.LinkTaskIssue(ctx.Request.Context(), ctx.Param("taskId"), req)
if err != nil {
c.handleTaskIssueLinkError(ctx, err)
return
}
ctx.JSON(http.StatusOK, resp)
}
func (c *Controller) httpUnlinkTaskIssue(ctx *gin.Context) {
if err := c.service.UnlinkTaskIssue(ctx.Request.Context(), ctx.Param("taskId")); err != nil {
c.handleTaskIssueLinkError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"unlinked": true})
}
func (c *Controller) handleTaskIssueLinkError(ctx *gin.Context, err error) {
switch {
case errors.Is(err, ErrNoClient):
ctx.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GitHub is not configured. Connect GitHub in Settings > Integrations.",
"code": "github_not_configured",
})
case errors.Is(err, ErrInvalidIssueReference):
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, ErrIssueRepositoryMismatch):
ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
case errors.Is(err, errStoreUnavailable):
c.logger.Error("task issue store unavailable", zap.Error(err))
ctx.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GitHub task issue linking is temporarily unavailable. Please try again.",
"code": "github_task_issue_unavailable",
})
case errors.Is(err, ErrTaskNotFound):
ctx.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
default:
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden:
c.logger.Debug("GitHub issue fetch failed", zap.Int("status", apiErr.StatusCode), zap.Error(err))
ctx.JSON(apiErr.StatusCode, gin.H{"error": "failed to fetch GitHub issue"})
return
}
}
c.logger.Error("task issue link request failed", zap.Error(err))
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to link GitHub issue"})
}
}
func (c *Controller) httpGetTaskCIOptions(ctx *gin.Context) {
resp, err := c.service.GetTaskCIOptionsResponse(ctx.Request.Context(), ctx.Param("taskId"))
if err != nil {
@@ -18,6 +18,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
// stubClient implements Client with no-op defaults; override fields as needed.
@@ -177,6 +178,234 @@ func setupControllerStoreTest(t *testing.T) (*gin.Engine, *Store) {
return router, store
}
func TestHttpLinkTaskIssue_SuccessAndInvalidJSON(t *testing.T) {
router, ctrl := setupControllerTest(&stubClient{
getIssueFunc: func(_ context.Context, owner, repo string, number int) (*Issue, error) {
return &Issue{
Number: number,
Title: "Link existing task",
HTMLURL: fmt.Sprintf("https://github.com/%s/%s/issues/%d", owner, repo, number),
RepoOwner: owner,
RepoName: repo,
}, nil
},
})
ctrl.service.SetTaskIssueStore(&fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{"keep": "me"}},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
entities: map[string]*taskmodels.Repository{
"repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"},
},
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/github/tasks/task-1/issue", bytes.NewBufferString(`{"issue":`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected invalid JSON status 400, got %d: %s", w.Code, w.Body.String())
}
assertJSONError(t, w.Body.Bytes(), "invalid payload")
req = httptest.NewRequest(http.MethodPut, "/api/v1/github/tasks/task-1/issue", bytes.NewBufferString(`{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected link status 200, got %d: %s", w.Code, w.Body.String())
}
var got TaskIssueLinkResponse
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got.TaskID != "task-1" || got.Owner != "kdlbs" || got.Repo != "kandev" || got.IssueNumber != 1470 {
t.Fatalf("unexpected response: %+v", got)
}
}
func TestHttpUnlinkTaskIssue_Success(t *testing.T) {
router, ctrl := setupControllerTest(&stubClient{})
store := &fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{
taskMetaIssueURL: "https://github.com/kdlbs/kandev/issues/1470",
taskMetaIssueNumber: 1470,
"keep": "me",
}},
}
ctrl.service.SetTaskIssueStore(store)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/github/tasks/task-1/issue", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected unlink status 200, got %d: %s", w.Code, w.Body.String())
}
var got map[string]bool
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if !got["unlinked"] {
t.Fatalf("unexpected response: %+v", got)
}
if _, ok := store.updated[taskMetaIssueURL]; ok {
t.Fatalf("issue metadata should be removed: %+v", store.updated)
}
}
func TestHttpUnlinkTaskIssue_TaskNotFound(t *testing.T) {
router, ctrl := setupControllerTest(&stubClient{})
ctrl.service.SetTaskIssueStore(&fakeTaskIssueStore{
taskErr: fmt.Errorf("task lookup failed: %w", ErrTaskNotFound),
})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/github/tasks/missing-task/issue", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected unlink task-not-found status 404, got %d: %s", w.Code, w.Body.String())
}
assertJSONError(t, w.Body.Bytes(), "task not found")
}
func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) {
tests := []struct {
name string
client Client
store TaskIssueStore
body string
wantStatus int
wantError string
wantCode string
}{
{
name: "no client",
client: nil,
store: &fakeTaskIssueStore{},
body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`,
wantStatus: http.StatusServiceUnavailable,
wantError: "GitHub is not configured. Connect GitHub in Settings > Integrations.",
wantCode: "github_not_configured",
},
{
name: "store unavailable",
client: &stubClient{},
body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`,
wantStatus: http.StatusServiceUnavailable,
wantError: "GitHub task issue linking is temporarily unavailable. Please try again.",
wantCode: "github_task_issue_unavailable",
},
{
name: "invalid reference",
client: &stubClient{},
store: &fakeTaskIssueStore{},
body: `{"issue":"#1470"}`,
wantStatus: http.StatusBadRequest,
wantError: "invalid GitHub issue reference: owner and repo are required for issue numbers",
},
{
name: "repository mismatch",
client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) {
return &Issue{Number: 1, HTMLURL: "https://github.com/other/repo/issues/1", RepoOwner: "other", RepoName: "repo"}, nil
}},
store: &fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
entities: map[string]*taskmodels.Repository{
"repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"},
},
},
body: `{"issue":"https://github.com/other/repo/issues/1"}`,
wantStatus: http.StatusUnprocessableEntity,
wantError: "GitHub issue repository is not attached to task",
},
{
name: "upstream not found",
client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) {
return nil, &GitHubAPIError{StatusCode: http.StatusNotFound, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"}
}},
store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}},
body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`,
wantStatus: http.StatusNotFound,
wantError: "failed to fetch GitHub issue",
},
{
name: "upstream unauthorized",
client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) {
return nil, &GitHubAPIError{StatusCode: http.StatusUnauthorized, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"}
}},
store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}},
body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`,
wantStatus: http.StatusUnauthorized,
wantError: "failed to fetch GitHub issue",
},
{
name: "upstream forbidden",
client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) {
return nil, &GitHubAPIError{StatusCode: http.StatusForbidden, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"}
}},
store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}},
body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`,
wantStatus: http.StatusForbidden,
wantError: "failed to fetch GitHub issue",
},
{
name: "task not found",
client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) {
return &Issue{Number: 1470, HTMLURL: "https://github.com/kdlbs/kandev/issues/1470", RepoOwner: "kdlbs", RepoName: "kandev"}, nil
}},
store: &fakeTaskIssueStore{taskErr: fmt.Errorf("task lookup failed: %w", ErrTaskNotFound)},
body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`,
wantStatus: http.StatusNotFound,
wantError: "task not found",
},
{
name: "default error",
client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) {
return &Issue{Number: 1470, HTMLURL: "https://github.com/kdlbs/kandev/issues/1470", RepoOwner: "kdlbs", RepoName: "kandev"}, nil
}},
store: &fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}},
updateErr: errors.New("database unavailable"),
},
body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`,
wantStatus: http.StatusInternalServerError,
wantError: "failed to link GitHub issue",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, ctrl := setupControllerTest(tt.client)
if tt.store != nil {
ctrl.service.SetTaskIssueStore(tt.store)
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/github/tasks/task-1/issue", bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d: %s", w.Code, tt.wantStatus, w.Body.String())
}
got := assertJSONError(t, w.Body.Bytes(), tt.wantError)
if tt.wantCode != "" && got["code"] != tt.wantCode {
t.Fatalf("code = %v, want %s; body=%+v", got["code"], tt.wantCode, got)
}
})
}
}
func assertJSONError(t *testing.T, body []byte, want string) map[string]string {
t.Helper()
var got map[string]string
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode error body: %v; body=%s", err, string(body))
}
if got["error"] != want {
t.Fatalf("error = %q, want %q; body=%+v", got["error"], want, got)
}
return got
}
func TestHttpTaskCIOptions_DefaultAndPatch(t *testing.T) {
router, store := setupControllerStoreTest(t)
ctx := context.Background()
+8
View File
@@ -10,6 +10,14 @@ import (
// into a 400 instead of a generic 500.
var ErrInvalidPRURL = errors.New("invalid PR URL")
// ErrInvalidIssueReference signals that a caller-supplied GitHub issue URL or
// number could not be resolved to a concrete issue.
var ErrInvalidIssueReference = errors.New("invalid GitHub issue reference")
// ErrIssueRepositoryMismatch signals that the issue belongs to a repository
// that is not attached to the target task.
var ErrIssueRepositoryMismatch = errors.New("GitHub issue repository is not attached to task")
// ErrTaskNotFound is the sentinel that cleanup paths check to distinguish
// "the task is already gone — fine, mop up the dedup row" from a real
// upstream failure. Adapter implementations of TaskDeleter wrap this when
+10 -9
View File
@@ -78,15 +78,16 @@ type PromptResolver interface {
// Service coordinates GitHub integration operations.
type Service struct {
mu sync.Mutex
client Client
authMethod string
secrets SecretProvider
secretManager SecretManager
store *Store
eventBus bus.EventBus
logger *logger.Logger
taskDeleter TaskDeleter
mu sync.Mutex
client Client
authMethod string
secrets SecretProvider
secretManager SecretManager
store *Store
eventBus bus.EventBus
logger *logger.Logger
taskDeleter TaskDeleter
taskIssueStore TaskIssueStore
// cascadeTaskDeleter is the cascade-delete entry point used by the
// watch reset flow. It is distinct from taskDeleter (which only deletes
// a single task by ID) because reset must walk the task tree and clean
@@ -327,6 +327,9 @@ func parsePRURL(prURL string) (owner, repo string, number int, err error) {
if err != nil {
return "", "", 0, fmt.Errorf("invalid PR number in URL %s: %w", prURL, err)
}
if number <= 0 {
return "", "", 0, fmt.Errorf("invalid PR number in URL %s: must be greater than zero", prURL)
}
// Parse owner/repo from path before /pull/
pathBefore := prURL[:idx]
@@ -0,0 +1,197 @@
package github
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
const (
taskMetaIssueURL = "issue_url"
taskMetaIssueNumber = "issue_number"
taskMetaIssueOwner = "issue_owner"
taskMetaIssueRepo = "issue_repo"
taskMetaIssueLinked = "github_issue_linked"
githubProviderName = "github"
githubIssuePathMarker = "/issues/"
)
// TaskIssueStore is the task-facing dependency needed to link GitHub issues.
// The backendapp adapter routes metadata writes through task.Service so the
// normal task.updated event is published.
type TaskIssueStore interface {
GetTask(ctx context.Context, taskID string) (*taskmodels.Task, error)
ListTaskRepositories(ctx context.Context, taskID string) ([]*taskmodels.TaskRepository, error)
GetRepository(ctx context.Context, repositoryID string) (*taskmodels.Repository, error)
UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*taskmodels.Task, error)
}
type LinkTaskIssueRequest struct {
Issue string `json:"issue"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
type TaskIssueLinkResponse struct {
TaskID string `json:"task_id"`
Owner string `json:"owner"`
Repo string `json:"repo"`
IssueNumber int `json:"issue_number"`
IssueURL string `json:"issue_url"`
IssueTitle string `json:"issue_title"`
}
func (s *Service) SetTaskIssueStore(store TaskIssueStore) {
s.mu.Lock()
defer s.mu.Unlock()
s.taskIssueStore = store
}
func (s *Service) getTaskIssueStore() TaskIssueStore {
s.mu.Lock()
defer s.mu.Unlock()
return s.taskIssueStore
}
func (s *Service) LinkTaskIssue(ctx context.Context, taskID string, req LinkTaskIssueRequest) (*TaskIssueLinkResponse, error) {
if s.client == nil {
return nil, ErrNoClient
}
store := s.getTaskIssueStore()
if store == nil {
return nil, errStoreUnavailable
}
owner, repo, number, err := resolveIssueReference(req)
if err != nil {
return nil, err
}
task, err := store.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
issue, err := s.client.GetIssue(ctx, owner, repo, number)
if err != nil {
return nil, err
}
if err := s.validateIssueTaskRepository(ctx, store, taskID, issue.RepoOwner, issue.RepoName); err != nil {
return nil, err
}
metadata := copyMetadata(task.Metadata)
metadata[taskMetaIssueURL] = issue.HTMLURL
metadata[taskMetaIssueNumber] = issue.Number
metadata[taskMetaIssueOwner] = issue.RepoOwner
metadata[taskMetaIssueRepo] = issue.RepoName
metadata[taskMetaIssueLinked] = true
if _, err := store.UpdateTaskMetadata(context.WithoutCancel(ctx), taskID, metadata); err != nil {
return nil, err
}
return taskIssueResponse(taskID, issue), nil
}
func (s *Service) UnlinkTaskIssue(ctx context.Context, taskID string) error {
store := s.getTaskIssueStore()
if store == nil {
return errStoreUnavailable
}
task, err := store.GetTask(ctx, taskID)
if err != nil {
return err
}
metadata := copyMetadata(task.Metadata)
delete(metadata, taskMetaIssueURL)
delete(metadata, taskMetaIssueNumber)
delete(metadata, taskMetaIssueOwner)
delete(metadata, taskMetaIssueRepo)
delete(metadata, taskMetaIssueLinked)
_, err = store.UpdateTaskMetadata(context.WithoutCancel(ctx), taskID, metadata)
return err
}
func resolveIssueReference(req LinkTaskIssueRequest) (string, string, int, error) {
if req.Issue != "" {
return parseIssueReference(req.Issue, req.Owner, req.Repo)
}
if req.Owner == "" || req.Repo == "" || req.Number <= 0 {
return "", "", 0, ErrInvalidIssueReference
}
return req.Owner, req.Repo, req.Number, nil
}
func parseIssueReference(input, defaultOwner, defaultRepo string) (string, string, int, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", "", 0, ErrInvalidIssueReference
}
if n, err := strconv.Atoi(strings.TrimPrefix(trimmed, "#")); err == nil && n > 0 {
if defaultOwner == "" || defaultRepo == "" {
return "", "", 0, fmt.Errorf("%w: owner and repo are required for issue numbers", ErrInvalidIssueReference)
}
return defaultOwner, defaultRepo, n, nil
}
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return "", "", 0, fmt.Errorf("%w: expected a GitHub issue URL", ErrInvalidIssueReference)
}
host := strings.TrimPrefix(strings.ToLower(u.Host), "www.")
if host != "github.com" || !strings.Contains(u.Path, githubIssuePathMarker) {
return "", "", 0, fmt.Errorf("%w: expected a GitHub issue URL", ErrInvalidIssueReference)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 4 || parts[2] != "issues" {
return "", "", 0, fmt.Errorf("%w: expected /owner/repo/issues/number", ErrInvalidIssueReference)
}
number, err := strconv.Atoi(parts[3])
if err != nil || number <= 0 {
return "", "", 0, fmt.Errorf("%w: invalid issue number", ErrInvalidIssueReference)
}
return parts[0], parts[1], number, nil
}
func (s *Service) validateIssueTaskRepository(ctx context.Context, store TaskIssueStore, taskID, owner, repo string) error {
taskRepos, err := store.ListTaskRepositories(ctx, taskID)
if err != nil {
return err
}
if len(taskRepos) == 0 {
return nil
}
for _, taskRepo := range taskRepos {
entity, err := store.GetRepository(ctx, taskRepo.RepositoryID)
if err != nil {
return err
}
if entity == nil {
continue
}
if strings.EqualFold(entity.Provider, githubProviderName) &&
strings.EqualFold(entity.ProviderOwner, owner) &&
strings.EqualFold(entity.ProviderName, repo) {
return nil
}
}
return ErrIssueRepositoryMismatch
}
func copyMetadata(metadata map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(metadata)+5)
for key, value := range metadata {
out[key] = value
}
return out
}
func taskIssueResponse(taskID string, issue *Issue) *TaskIssueLinkResponse {
return &TaskIssueLinkResponse{
TaskID: taskID,
Owner: issue.RepoOwner,
Repo: issue.RepoName,
IssueNumber: issue.Number,
IssueURL: issue.HTMLURL,
IssueTitle: issue.Title,
}
}
@@ -0,0 +1,287 @@
package github
import (
"context"
"errors"
"testing"
taskmodels "github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type fakeTaskIssueStore struct {
task *taskmodels.Task
repos []*taskmodels.TaskRepository
entities map[string]*taskmodels.Repository
repositoryErrs map[string]error
failOnCanceledUpdate bool
// taskErr takes priority over the task-not-set fallback. Set it to an
// error wrapping ErrTaskNotFound when a test needs the controller's 404 path.
taskErr error
updateErr error
updated map[string]interface{}
}
type countingIssueClient struct {
*MockClient
getIssueCalls int
}
func (c *countingIssueClient) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
c.getIssueCalls++
return c.MockClient.GetIssue(ctx, owner, repo, number)
}
func (f *fakeTaskIssueStore) GetTask(_ context.Context, taskID string) (*taskmodels.Task, error) {
if f.taskErr != nil {
return nil, f.taskErr
}
if f.task == nil || f.task.ID != taskID {
return nil, errors.New("task not found")
}
return f.task, nil
}
func (f *fakeTaskIssueStore) ListTaskRepositories(_ context.Context, taskID string) ([]*taskmodels.TaskRepository, error) {
if f.taskErr != nil {
return nil, f.taskErr
}
if f.task == nil || f.task.ID != taskID {
return nil, errors.New("task not found")
}
return f.repos, nil
}
func (f *fakeTaskIssueStore) GetRepository(_ context.Context, repositoryID string) (*taskmodels.Repository, error) {
if err := f.repositoryErrs[repositoryID]; err != nil {
return nil, err
}
if repo := f.entities[repositoryID]; repo != nil {
return repo, nil
}
return nil, errors.New("repository not found")
}
func (f *fakeTaskIssueStore) UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*taskmodels.Task, error) {
if f.taskErr != nil {
return nil, f.taskErr
}
if f.task == nil || f.task.ID != taskID {
return nil, errors.New("task not found")
}
if f.failOnCanceledUpdate && ctx.Err() != nil {
return nil, ctx.Err()
}
if f.updateErr != nil {
return nil, f.updateErr
}
f.updated = metadata
f.task.Metadata = metadata
return f.task, nil
}
func TestLinkTaskIssue_MergesIssueMetadataAndPreservesState(t *testing.T) {
client := NewMockClient()
client.AddIssue(&Issue{
Number: 1470,
Title: "Link existing task",
HTMLURL: "https://github.com/kdlbs/kandev/issues/1470",
RepoOwner: "kdlbs",
RepoName: "kandev",
State: "open",
})
store := &fakeTaskIssueStore{
failOnCanceledUpdate: true,
task: &taskmodels.Task{
ID: "task-1",
State: v1.TaskStateInProgress,
Metadata: map[string]interface{}{"keep": "me"},
},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
entities: map[string]*taskmodels.Repository{
"repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"},
},
}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
ctx, cancel := context.WithCancel(context.Background())
cancel()
resp, err := svc.LinkTaskIssue(ctx, "task-1", LinkTaskIssueRequest{
Issue: "https://github.com/kdlbs/kandev/issues/1470",
})
if err != nil {
t.Fatalf("LinkTaskIssue: %v", err)
}
if resp.IssueNumber != 1470 || resp.IssueURL == "" {
t.Fatalf("unexpected response: %+v", resp)
}
if store.task.State != v1.TaskStateInProgress {
t.Fatalf("state changed: %s", store.task.State)
}
if store.updated["keep"] != "me" {
t.Fatalf("existing metadata was not preserved: %+v", store.updated)
}
if store.updated[taskMetaIssueURL] != "https://github.com/kdlbs/kandev/issues/1470" {
t.Fatalf("issue url not written: %+v", store.updated)
}
if store.updated[taskMetaIssueNumber] != 1470 {
t.Fatalf("issue number not written: %+v", store.updated)
}
}
func TestLinkTaskIssue_PropagatesRepositoryLookupError(t *testing.T) {
client := NewMockClient()
client.AddIssue(&Issue{
Number: 1470,
Title: "Link existing task",
HTMLURL: "https://github.com/kdlbs/kandev/issues/1470",
RepoOwner: "kdlbs",
RepoName: "kandev",
})
repoErr := errors.New("repository store unavailable")
store := &fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
repositoryErrs: map[string]error{"repo-1": repoErr},
}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
_, err := svc.LinkTaskIssue(context.Background(), "task-1", LinkTaskIssueRequest{
Issue: "https://github.com/kdlbs/kandev/issues/1470",
})
if !errors.Is(err, repoErr) {
t.Fatalf("err = %v, want repository lookup error", err)
}
if errors.Is(err, ErrIssueRepositoryMismatch) {
t.Fatalf("repository lookup error should not be masked as mismatch: %v", err)
}
if store.updated != nil {
t.Fatalf("metadata should not be updated: %+v", store.updated)
}
}
func TestLinkTaskIssue_ChecksTaskBeforeFetchingIssue(t *testing.T) {
client := &countingIssueClient{MockClient: NewMockClient()}
taskErr := errors.New("task lookup failed")
store := &fakeTaskIssueStore{taskErr: taskErr}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
_, err := svc.LinkTaskIssue(context.Background(), "missing-task", LinkTaskIssueRequest{
Issue: "https://github.com/kdlbs/kandev/issues/1470",
})
if !errors.Is(err, taskErr) {
t.Fatalf("expected task lookup error before GitHub fetch, got %v", err)
}
if client.getIssueCalls != 0 {
t.Fatalf("GetIssue called %d times before task lookup succeeded", client.getIssueCalls)
}
}
func TestLinkTaskIssue_RejectsIssueFromDifferentTaskRepository(t *testing.T) {
client := NewMockClient()
client.AddIssue(&Issue{
Number: 1,
Title: "Wrong repo",
HTMLURL: "https://github.com/other/repo/issues/1",
RepoOwner: "other",
RepoName: "repo",
})
store := &fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
entities: map[string]*taskmodels.Repository{
"repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"},
},
}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
_, err := svc.LinkTaskIssue(context.Background(), "task-1", LinkTaskIssueRequest{
Issue: "https://github.com/other/repo/issues/1",
})
if !errors.Is(err, ErrIssueRepositoryMismatch) {
t.Fatalf("err = %v, want ErrIssueRepositoryMismatch", err)
}
if store.updated != nil {
t.Fatalf("metadata should not be updated: %+v", store.updated)
}
}
func TestUnlinkTaskIssue_RemovesIssueMetadataOnly(t *testing.T) {
store := &fakeTaskIssueStore{
failOnCanceledUpdate: true,
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{
taskMetaIssueURL: "https://github.com/kdlbs/kandev/issues/1470",
taskMetaIssueNumber: 1470,
taskMetaIssueOwner: "kdlbs",
taskMetaIssueRepo: "kandev",
taskMetaIssueLinked: true,
"issue_watch_id": "watch-1",
"keep": "me",
}},
}
svc := NewService(NewMockClient(), AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := svc.UnlinkTaskIssue(ctx, "task-1"); err != nil {
t.Fatalf("UnlinkTaskIssue: %v", err)
}
if _, ok := store.updated[taskMetaIssueURL]; ok {
t.Fatalf("issue url should be removed: %+v", store.updated)
}
if store.updated["issue_watch_id"] != "watch-1" {
t.Fatalf("watch metadata should be preserved: %+v", store.updated)
}
if store.updated["keep"] != "me" {
t.Fatalf("unrelated metadata should be preserved: %+v", store.updated)
}
}
func TestParseIssueReference(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
defaultOwner string
defaultRepo string
wantOwner string
wantRepo string
wantNumber int
wantErr bool
}{
{name: "empty", input: " ", wantErr: true},
{name: "hash number with default repo", input: "#1470", defaultOwner: "kdlbs", defaultRepo: "kandev", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "bare number with default repo", input: "1470", defaultOwner: "kdlbs", defaultRepo: "kandev", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "number without default repo", input: "#1470", defaultOwner: "kdlbs", wantErr: true},
{name: "valid github url", input: "https://github.com/kdlbs/kandev/issues/1470", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "www github url", input: "https://www.github.com/kdlbs/kandev/issues/1470?foo=bar", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "non github host", input: "https://gitlab.com/kdlbs/kandev/issues/1470", wantErr: true},
{name: "pull request url", input: "https://github.com/kdlbs/kandev/pull/1470", wantErr: true},
{name: "missing issue number", input: "https://github.com/kdlbs/kandev/issues", wantErr: true},
{name: "invalid issue number", input: "https://github.com/kdlbs/kandev/issues/nope", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
owner, repo, number, err := parseIssueReference(tt.input, tt.defaultOwner, tt.defaultRepo)
if tt.wantErr {
if !errors.Is(err, ErrInvalidIssueReference) {
t.Fatalf("err = %v, want ErrInvalidIssueReference", err)
}
return
}
if err != nil {
t.Fatalf("parseIssueReference: %v", err)
}
if owner != tt.wantOwner || repo != tt.wantRepo || number != tt.wantNumber {
t.Fatalf("got %s/%s#%d, want %s/%s#%d", owner, repo, number, tt.wantOwner, tt.wantRepo, tt.wantNumber)
}
})
}
}
@@ -256,6 +256,16 @@ func TestParsePRURL(t *testing.T) {
url: "https://github.com/owner/repo/pull/abc",
wantErr: true,
},
{
name: "zero PR number",
url: "https://github.com/owner/repo/pull/0",
wantErr: true,
},
{
name: "negative PR number",
url: "https://github.com/owner/repo/pull/-1",
wantErr: true,
},
{
name: "too short path",
url: "/pull/1",