Files
kandev/apps/backend/internal/github/service_task_issue.go
T
2026-08-31 20:58:27 +00:00

309 lines
9.3 KiB
Go

package github
import (
"context"
"encoding/json"
"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 used by GitHub task links.
// It supplies task repositories for PR association validation and task
// metadata for issue links. 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"`
TaskTitle string `json:"task_title"`
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
}
return s.linkTaskIssueWithClient(ctx, s.client, taskID, req)
}
// LinkTaskIssueForUser derives workspace ownership from the task before
// resolving a personal-read credential. A task without ownership fails closed.
func (s *Service) LinkTaskIssueForUser(
ctx context.Context, userID, taskID string, req LinkTaskIssueRequest,
) (*TaskIssueLinkResponse, error) {
store := s.getTaskIssueStore()
if store == nil {
return nil, errStoreUnavailable
}
task, err := store.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
if task == nil || strings.TrimSpace(task.WorkspaceID) == "" {
return nil, ErrGitHubWorkspaceRequired
}
owner, repo, number, err := resolveIssueReference(req)
if err != nil {
return nil, err
}
if err := s.ensureRepositoryInWorkspaceScope(ctx, task.WorkspaceID, owner, repo); err != nil {
return nil, err
}
resolved, err := s.resolvePersonalReadClient(ctx, task.WorkspaceID, userID, owner, repo)
if err != nil {
return nil, err
}
return s.linkTaskIssueResolved(ctx, resolved.Client, store, task, taskID, owner, repo, number)
}
func (s *Service) linkTaskIssueWithClient(
ctx context.Context, client Client, taskID string, req LinkTaskIssueRequest,
) (*TaskIssueLinkResponse, error) {
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
}
return s.linkTaskIssueResolved(ctx, client, store, task, taskID, owner, repo, number)
}
func (s *Service) linkTaskIssueResolved(
ctx context.Context,
client Client,
store TaskIssueStore,
task *taskmodels.Task,
taskID string,
owner string,
repo string,
number int,
) (*TaskIssueLinkResponse, error) {
issue, err := 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, task.Title, issue), nil
}
// ListWorkspaceTaskIssues returns persisted GitHub issue links for one workspace.
func (s *Service) ListWorkspaceTaskIssues(ctx context.Context, workspaceID string) (map[string]TaskIssueLinkResponse, error) {
if s.store == nil {
return nil, errStoreUnavailable
}
rows, err := s.store.ListTaskIssueMetadataByWorkspaceID(ctx, workspaceID)
if err != nil {
return nil, err
}
result := make(map[string]TaskIssueLinkResponse)
for _, row := range rows {
link, ok := taskIssueLinkFromMetadata(row)
if ok {
result[row.TaskID] = link
}
}
return result, 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 taskIssueLinkFromMetadata(row taskIssueMetadataRow) (TaskIssueLinkResponse, bool) {
var metadata map[string]interface{}
if err := json.Unmarshal([]byte(row.Metadata), &metadata); err != nil {
return TaskIssueLinkResponse{}, false
}
issueURL, ok := metadata[taskMetaIssueURL].(string)
if !ok || issueURL == "" {
return TaskIssueLinkResponse{}, false
}
metadataNumber, ok := positiveMetadataInt(metadata[taskMetaIssueNumber])
if !ok {
return TaskIssueLinkResponse{}, false
}
owner, repo, issueNumber, err := parseIssueReference(issueURL, "", "")
if err != nil || issueNumber != metadataNumber {
return TaskIssueLinkResponse{}, false
}
return TaskIssueLinkResponse{
TaskID: row.TaskID,
TaskTitle: row.TaskTitle,
Owner: owner,
Repo: repo,
IssueNumber: issueNumber,
IssueURL: issueURL,
}, true
}
func positiveMetadataInt(value interface{}) (int, bool) {
switch number := value.(type) {
case int:
return number, number > 0
case float64:
integer := int(number)
return integer, integer > 0 && float64(integer) == number
default:
return 0, false
}
}
func taskIssueResponse(taskID, taskTitle string, issue *Issue) *TaskIssueLinkResponse {
return &TaskIssueLinkResponse{
TaskID: taskID,
TaskTitle: taskTitle,
Owner: issue.RepoOwner,
Repo: issue.RepoName,
IssueNumber: issue.Number,
IssueURL: issue.HTMLURL,
IssueTitle: issue.Title,
}
}