feat: link tasks to github references (#1471)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user