8b6ec7f639
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
309 lines
9.3 KiB
Go
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,
|
|
}
|
|
}
|