diff --git a/config/repo.go b/config/repo.go index 0421af8..d2c5e46 100644 --- a/config/repo.go +++ b/config/repo.go @@ -181,6 +181,7 @@ func defaultBranch(rules []BranchRule, fallback string) string { func issues(assignmentKey string) *IssueReplication { replicate := viper.GetBool(assignmentKey + ".issues.replicateFromStartercode") numbers := viper.GetIntSlice(assignmentKey + ".issues.issueNumbers") + includeChildTasks := viper.GetBool(assignmentKey + ".issues.includeChildTasks") // Legacy compatibility for old startercode issue replication config. if !replicate && !viper.IsSet(assignmentKey+".issues") { @@ -196,7 +197,7 @@ func issues(assignmentKey string) *IssueReplication { numbers = []int{1} } - return &IssueReplication{ReplicateFromStartercode: true, IssueNumbers: numbers} + return &IssueReplication{ReplicateFromStartercode: true, IssueNumbers: numbers, IncludeChildTasks: includeChildTasks} } func clone(assignmentKey, defaultBranch string) *Clone { diff --git a/config/repo_test.go b/config/repo_test.go index e5999d1..e499815 100644 --- a/config/repo_test.go +++ b/config/repo_test.go @@ -126,13 +126,20 @@ func TestIssues_DefaultsAndLegacyFallback(t *testing.T) { if !reflect.DeepEqual(i.IssueNumbers, []int{1}) { t.Fatalf("IssueNumbers = %#v, want [1]", i.IssueNumbers) } + if i.IncludeChildTasks { + t.Fatalf("IncludeChildTasks = %v, want false", i.IncludeChildTasks) + } viper.Set("course.a1.issues.replicateFromStartercode", true) viper.Set("course.a1.issues.issueNumbers", []int{4, 7}) + viper.Set("course.a1.issues.includeChildTasks", true) i = issues("course.a1") if !reflect.DeepEqual(i.IssueNumbers, []int{4, 7}) { t.Fatalf("IssueNumbers = %#v", i.IssueNumbers) } + if !i.IncludeChildTasks { + t.Fatalf("IncludeChildTasks = %v, want true", i.IncludeChildTasks) + } } func TestCloneDefaultsAndOverrides(t *testing.T) { diff --git a/config/show.go b/config/show.go index 0034446..1ec835b 100644 --- a/config/show.go +++ b/config/show.go @@ -113,6 +113,7 @@ func (cfg *AssignmentConfig) Show() { fieldCandidate(2, "AdditionalBranches"), fieldCandidate(2, "ReplicateFromStartercode"), fieldCandidate(2, "IssueNumbers"), + fieldCandidate(2, "IncludeChildTasks"), fieldCandidate(2, "MergeMethod"), fieldCandidate(2, "SquashOption"), fieldCandidate(2, "PipelineMustSucceed"), @@ -182,8 +183,10 @@ func (cfg *AssignmentConfig) Show() { writeSectionField("ReplicateFromStartercode", cfg.Issues.ReplicateFromStartercode) if cfg.Issues.ReplicateFromStartercode { writeSectionField("IssueNumbers", cfg.Issues.IssueNumbers) + writeSectionField("IncludeChildTasks", cfg.Issues.IncludeChildTasks) } else { writeSectionField("IssueNumbers", "not used") + writeSectionField("IncludeChildTasks", "not used") } } diff --git a/config/types.go b/config/types.go index 2876b4a..12ca586 100644 --- a/config/types.go +++ b/config/types.go @@ -94,6 +94,7 @@ type BranchRule struct { type IssueReplication struct { ReplicateFromStartercode bool IssueNumbers []int + IncludeChildTasks bool } type Clone struct { diff --git a/docs/configuration.md b/docs/configuration.md index 4de547c..a6609a9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -246,6 +246,7 @@ Configure issue replication separately from startercode: issues: replicateFromStartercode: true issueNumbers: [1, 3] + includeChildTasks: false ``` ### Issue keys @@ -254,6 +255,7 @@ issues: |---|---|---|---| | `replicateFromStartercode` | Copy issues from starter project | `false` | Requires `startercode` | | `issueNumbers` | Which issue numbers to copy | `[1]` | Used only when replication is enabled | +| `includeChildTasks` | Expand issueNumbers by linked child tasks (GraphQL) | `false` | Resolves nested child tasks recursively | **Example: Merge-only development branch** diff --git a/git/sourcerepo.go b/git/sourcerepo.go index 41371a5..3bc6653 100644 --- a/git/sourcerepo.go +++ b/git/sourcerepo.go @@ -41,8 +41,29 @@ func PrepareSourceRepo(url, fromBranch string, singleCommit bool, commitMessage log.Debug().Err(err).Msg("cannot start spinner") } + cloneSucceeded := false + defer func() { + if spinner == nil { + return + } + + if cloneSucceeded { + errs := spinner.Stop() + if errs != nil { + log.Debug().Err(errs).Msg("cannot stop spinner") + } + return + } + + err := spinner.StopFail() + if err != nil { + log.Debug().Err(err).Msg("cannot stop spinner") + } + }() + auth, err := GetAuth() if err != nil { + spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) fmt.Printf("error: %v", err) return nil, err } @@ -60,22 +81,12 @@ func PrepareSourceRepo(url, fromBranch string, singleCommit bool, commitMessage }) if err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } wt, err := repo.Worktree() if err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } @@ -84,15 +95,11 @@ func PrepareSourceRepo(url, fromBranch string, singleCommit bool, commitMessage Force: true, }); err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } if !singleCommit { + cloneSucceeded = true return &SourceRepo{ Repo: repo, Ref: sourceRef, @@ -103,33 +110,18 @@ func PrepareSourceRepo(url, fromBranch string, singleCommit bool, commitMessage headRef, err := repo.Head() if err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } headCommit, err := repo.CommitObject(headRef.Hash()) if err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } tree, err := headCommit.Tree() if err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } @@ -163,32 +155,17 @@ func PrepareSourceRepo(url, fromBranch string, singleCommit bool, commitMessage encoded := repo.Storer.NewEncodedObject() if err := commit.Encode(encoded); err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } commitHash, err := repo.Storer.SetEncodedObject(encoded) if err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash)); err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } @@ -197,11 +174,6 @@ func PrepareSourceRepo(url, fromBranch string, singleCommit bool, commitMessage Merge: refName, }); err != nil && err != git.ErrBranchExists { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } @@ -210,21 +182,13 @@ func PrepareSourceRepo(url, fromBranch string, singleCommit bool, commitMessage Force: true, }); err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) - - err := spinner.StopFail() - if err != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } return nil, err } if singleCommit { spinner.StopMessage(fmt.Sprintf("using branch '%s' with single commit '%s'", refName.Short(), commitMessage)) } - errs := spinner.Stop() - if errs != nil { - log.Debug().Err(err).Msg("cannot stop spinner") - } + cloneSucceeded = true return &SourceRepo{ Repo: repo, diff --git a/gitlab/generate.go b/gitlab/generate.go index 90c6a31..4bec0c1 100644 --- a/gitlab/generate.go +++ b/gitlab/generate.go @@ -178,8 +178,26 @@ func (c *Client) generate(assignmentCfg *config.AssignmentConfig, assignmentGrou // Replicate issues from startercode repo if configured if generated && assignmentCfg.Startercode != nil && assignmentCfg.Issues != nil && assignmentCfg.Issues.ReplicateFromStartercode { starterProject, starterProjectErr := c.getStartercodeProject(assignmentCfg) + issueNumbers := assignmentCfg.Issues.IssueNumbers + parentByChild := make(map[int]int) + + if starterProjectErr == nil { + plan, planErr := c.resolveIssuePlanForReplication( + starterProject, + assignmentCfg.Issues.IssueNumbers, + assignmentCfg.Issues.IncludeChildTasks, + ) + if planErr != nil { + starterProjectErr = planErr + } else { + issueNumbers = plan.OrderedIssues + parentByChild = plan.ParentByChild + } + } - for _, issueNumber := range assignmentCfg.Issues.IssueNumbers { + createdIssueMap := make(map[int]int, len(issueNumbers)) + + for _, issueNumber := range issueNumbers { cfg.Suffix = aurora.Sprintf( aurora.Cyan(" ↪ replicating issue #%d from startercode"), aurora.Yellow(issueNumber), @@ -203,7 +221,9 @@ func (c *Client) generate(assignmentCfg *config.AssignmentConfig, assignmentGrou continue } - err = c.replicateIssue(starterProject, project, issueNumber) + _, isChildTask := parentByChild[issueNumber] + createdIssueIID, replicateErr := c.replicateIssue(starterProject, project, issueNumber, isChildTask) + err = replicateErr if err != nil { spinner.StopFailMessage(fmt.Sprintf("problem: %v", err)) @@ -214,11 +234,32 @@ func (c *Client) generate(assignmentCfg *config.AssignmentConfig, assignmentGrou continue } + createdIssueMap[issueNumber] = createdIssueIID + err = spinner.Stop() if err != nil { log.Debug().Err(err).Msg("cannot stop spinner") } } + + if starterProjectErr == nil && assignmentCfg.Issues.IncludeChildTasks { + for childSource, parentSource := range parentByChild { + parentTarget, hasParent := createdIssueMap[parentSource] + childTarget, hasChild := createdIssueMap[childSource] + if !hasParent || !hasChild { + continue + } + + if err = c.attachChildTaskToParent(project, parentTarget, childTarget); err != nil { + log.Error().Err(err). + Int("sourceParentIssue", parentSource). + Int("sourceChildIssue", childSource). + Int("targetParentIssue", parentTarget). + Int("targetChildIssue", childTarget). + Msg("cannot attach replicated child task to parent") + } + } + } } c.setaccess(assignmentCfg, project, members, &cfg) diff --git a/gitlab/issues.go b/gitlab/issues.go index c0a7720..1f28340 100644 --- a/gitlab/issues.go +++ b/gitlab/issues.go @@ -1,15 +1,100 @@ package gitlab import ( + "context" "fmt" "regexp" "strings" + "time" "github.com/obcode/glabs/v2/config" "github.com/rs/zerolog/log" gitlab "gitlab.com/gitlab-org/api/client-go/v2" ) +type issueReplicationPayload struct { + Number int + Title string + Description string + WorkItemType string + ChildIIDs []int +} + +type issueReplicationPlan struct { + OrderedIssues []int + ParentByChild map[int]int +} + +const issueChildrenGraphQLQuery = ` +query IssueChildren($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + issue(iid: $iid) { + workItem { + widgets { + ... on WorkItemWidgetHierarchy { + children(first: 100) { + nodes { + iid + } + } + } + } + } + } + } +} +` + +type issueChildrenGraphQLResponse struct { + Data struct { + Project *struct { + Issue *struct { + WorkItem *struct { + Widgets []struct { + Children struct { + Nodes []struct { + IID string `json:"iid"` + } `json:"nodes"` + } `json:"children"` + } `json:"widgets"` + } `json:"workItem"` + } `json:"issue"` + } `json:"project"` + } `json:"data"` +} + +const issueChildrenByParentGraphQLQuery = ` +query IssueChildrenByParent($fullPath: ID!, $parentIds: [WorkItemID!], $after: String) { + namespace(fullPath: $fullPath) { + workItems(parentIds: $parentIds, first: 100, after: $after) { + pageInfo { + endCursor + hasNextPage + } + nodes { + iid + } + } + } +} +` + +type issueChildrenByParentGraphQLResponse struct { + Data struct { + Namespace *struct { + WorkItems struct { + PageInfo struct { + EndCursor string `json:"endCursor"` + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` + Nodes []struct { + IID string `json:"iid"` + } `json:"nodes"` + } `json:"workItems"` + } `json:"namespace"` + } `json:"data"` +} + // getStartercodeProject extracts the project path from the startercode URL and returns the GitLab project func (c *Client) getStartercodeProject(assignmentCfg *config.AssignmentConfig) (*gitlab.Project, error) { // Parse project path from URL @@ -55,11 +140,44 @@ func (c *Client) getStartercodeProject(assignmentCfg *config.AssignmentConfig) ( } // replicateIssue loads a single issue from source project and creates it in target project -func (c *Client) replicateIssue(sourceProject *gitlab.Project, targetProject *gitlab.Project, issueNumber int) error { - // Load issue from startercode project - issue, _, err := c.Issues.GetIssue(sourceProject.ID, int64(issueNumber), nil) +func (c *Client) replicateIssue(sourceProject *gitlab.Project, targetProject *gitlab.Project, issueNumber int, asTask bool) (int, error) { + issue, err := c.loadIssueForReplication(sourceProject, issueNumber, false) if err != nil { - return fmt.Errorf("could not get issue from startercode project %d with number %d: %w", sourceProject.ID, issueNumber, err) + return 0, err + } + + if asTask { + targetProjectPath, pathErr := c.getProjectPathForGraphQL(targetProject) + if pathErr != nil { + return 0, pathErr + } + + workItemTypeID, ok := workItemTypeIDForName("task") + if !ok { + return 0, fmt.Errorf("task work item type is not available") + } + + createWorkItemOpts := &gitlab.CreateWorkItemOptions{Title: issue.Title} + if issue.Description != "" { + desc := issue.Description + createWorkItemOpts.Description = &desc + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + createdWI, _, createErr := c.WorkItems.CreateWorkItem(targetProjectPath, workItemTypeID, createWorkItemOpts, gitlab.WithContext(ctx)) + if createErr == nil { + log.Debug(). + Str("issueTitle", issue.Title). + Str("issueType", "Task"). + Str("targetProject", targetProject.PathWithNamespace). + Msg("successfully replicated issue via work items GraphQL") + + return int(createdWI.IID), nil + } + + return 0, fmt.Errorf("could not create task work item in target project %d: %w", targetProject.ID, createErr) } // Create issue in target project @@ -68,9 +186,9 @@ func (c *Client) replicateIssue(sourceProject *gitlab.Project, targetProject *gi Description: gitlab.Ptr(issue.Description), } - _, _, err = c.Issues.CreateIssue(targetProject.ID, createIssueOpts) + created, _, err := c.Issues.CreateIssue(targetProject.ID, createIssueOpts) if err != nil { - return fmt.Errorf("could not create issue %q in target project %d: %w", issue.Title, targetProject.ID, err) + return 0, fmt.Errorf("could not create issue %q in target project %d: %w", issue.Title, targetProject.ID, err) } log.Debug(). @@ -78,5 +196,257 @@ func (c *Client) replicateIssue(sourceProject *gitlab.Project, targetProject *gi Str("targetProject", targetProject.PathWithNamespace). Msg("successfully replicated issue") + return int(created.IID), nil +} + +func (c *Client) resolveIssuePlanForReplication(sourceProject *gitlab.Project, issueNumbers []int, includeChildTasks bool) (*issueReplicationPlan, error) { + ordered := make([]int, 0, len(issueNumbers)) + seen := make(map[int]struct{}, len(issueNumbers)) + parentByChild := make(map[int]int) + + queue := make([]int, 0, len(issueNumbers)) + queue = append(queue, issueNumbers...) + + for len(queue) > 0 { + issueNumber := queue[0] + queue = queue[1:] + + if _, exists := seen[issueNumber]; exists { + continue + } + + seen[issueNumber] = struct{}{} + ordered = append(ordered, issueNumber) + + if !includeChildTasks { + continue + } + + issue, err := c.loadIssueForReplication(sourceProject, issueNumber, true) + if err != nil { + return nil, err + } + + for _, child := range issue.ChildIIDs { + if _, exists := parentByChild[child]; !exists { + parentByChild[child] = issueNumber + } + + if _, exists := seen[child]; exists { + continue + } + queue = append(queue, child) + } + } + + return &issueReplicationPlan{OrderedIssues: ordered, ParentByChild: parentByChild}, nil +} + +func (c *Client) resolveIssueNumbersForReplication(sourceProject *gitlab.Project, issueNumbers []int, includeChildTasks bool) ([]int, error) { + plan, err := c.resolveIssuePlanForReplication(sourceProject, issueNumbers, includeChildTasks) + if err != nil { + return nil, err + } + + return plan.OrderedIssues, nil +} + +func (c *Client) loadIssueForReplication(sourceProject *gitlab.Project, issueNumber int, includeChildTasks bool) (*issueReplicationPayload, error) { + projectPath, err := c.getProjectPathForGraphQL(sourceProject) + if err != nil { + return nil, err + } + + issue, _, err := c.Issues.GetIssue(sourceProject.ID, int64(issueNumber), nil) + if err != nil { + return nil, fmt.Errorf("could not get issue from startercode project %s with number %d: %w", projectPath, issueNumber, err) + } + + result := &issueReplicationPayload{ + Number: issueNumber, + Title: issue.Title, + Description: issue.Description, + WorkItemType: "Issue", + } + + if !includeChildTasks { + return result, nil + } + + childIIDs, childErr := c.listChildIIDsByParentLookup(projectPath, issue.ID) + if childErr == nil { + result.ChildIIDs = append(result.ChildIIDs, childIIDs...) + if len(childIIDs) > 0 { + log.Debug().Int("issue", issueNumber).Ints("childIIDs", childIIDs).Msg("resolved child tasks via parent lookup") + return result, nil + } + } + + childIIDs, childErr = c.listChildIIDsByIssueGraphQL(projectPath, issueNumber) + if childErr != nil { + log.Debug().Err(childErr).Int("issue", issueNumber).Str("project", projectPath).Msg("could not resolve child tasks from fallback issue query; continuing without children") + return result, nil + } + result.ChildIIDs = append(result.ChildIIDs, childIIDs...) + if len(childIIDs) > 0 { + log.Debug().Int("issue", issueNumber).Ints("childIIDs", childIIDs).Msg("resolved child tasks via hierarchy widget query") + } + + return result, nil +} + +func (c *Client) listChildIIDsByParentLookup(projectPath string, parentIssueID int64) ([]int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + parentGID := fmt.Sprintf("gid://gitlab/WorkItem/%d", parentIssueID) + after := "" + childIIDs := make([]int, 0) + + for { + var response issueChildrenByParentGraphQLResponse + variables := map[string]any{ + "fullPath": projectPath, + "parentIds": []string{parentGID}, + } + if after != "" { + variables["after"] = after + } + + _, err := c.GraphQL.Do(gitlab.GraphQLQuery{Query: issueChildrenByParentGraphQLQuery, Variables: variables}, &response, gitlab.WithContext(ctx)) + if err != nil { + return nil, err + } + + if response.Data.Namespace == nil { + return childIIDs, nil + } + + for _, node := range response.Data.Namespace.WorkItems.Nodes { + var iid int + if _, err := fmt.Sscanf(node.IID, "%d", &iid); err != nil { + return nil, fmt.Errorf("invalid child iid %q", node.IID) + } + childIIDs = append(childIIDs, iid) + } + + if !response.Data.Namespace.WorkItems.PageInfo.HasNextPage { + break + } + + after = response.Data.Namespace.WorkItems.PageInfo.EndCursor + if after == "" { + break + } + } + + return childIIDs, nil +} + +func (c *Client) listChildIIDsByIssueGraphQL(projectPath string, issueNumber int) ([]int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + var response issueChildrenGraphQLResponse + _, err := c.GraphQL.Do(gitlab.GraphQLQuery{ + Query: issueChildrenGraphQLQuery, + Variables: map[string]any{ + "fullPath": projectPath, + "iid": fmt.Sprintf("%d", issueNumber), + }, + }, &response, gitlab.WithContext(ctx)) + if err != nil { + return nil, err + } + + if response.Data.Project == nil || response.Data.Project.Issue == nil || response.Data.Project.Issue.WorkItem == nil { + return nil, nil + } + + childIIDs := make([]int, 0) + for _, widget := range response.Data.Project.Issue.WorkItem.Widgets { + for _, node := range widget.Children.Nodes { + var iid int + _, scanErr := fmt.Sscanf(node.IID, "%d", &iid) + if scanErr != nil { + return nil, fmt.Errorf("invalid child iid %q", node.IID) + } + childIIDs = append(childIIDs, iid) + } + } + + return childIIDs, nil +} + +func workItemTypeIDForName(typeName string) (gitlab.WorkItemTypeID, bool) { + switch strings.ToLower(strings.TrimSpace(typeName)) { + case "issue": + return gitlab.WorkItemTypeIssue, true + case "task": + return gitlab.WorkItemTypeTask, true + case "incident": + return gitlab.WorkItemTypeIncident, true + case "test case", "testcase": + return gitlab.WorkItemTypeTestCase, true + case "requirement": + return gitlab.WorkItemTypeRequirement, true + case "objective": + return gitlab.WorkItemTypeObjective, true + case "key result", "keyresult": + return gitlab.WorkItemTypeKeyResult, true + case "epic": + return gitlab.WorkItemTypeEpic, true + case "ticket": + return gitlab.WorkItemTypeTicket, true + default: + return "", false + } +} + +func (c *Client) attachChildTaskToParent(targetProject *gitlab.Project, parentIssueIID int, childIssueIID int) error { + projectPath, err := c.getProjectPathForGraphQL(targetProject) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + parentWI, _, err := c.WorkItems.GetWorkItem(projectPath, int64(parentIssueIID), gitlab.WithContext(ctx)) + if err != nil { + return fmt.Errorf("could not load target parent issue #%d as work item: %w", parentIssueIID, err) + } + + parentID := parentWI.ID + _, _, err = c.WorkItems.UpdateWorkItem(projectPath, int64(childIssueIID), &gitlab.UpdateWorkItemOptions{ParentID: &parentID}, gitlab.WithContext(ctx)) + if err != nil { + return fmt.Errorf("could not attach child issue #%d to parent issue #%d in %s: %w", childIssueIID, parentIssueIID, projectPath, err) + } + return nil } + +func (c *Client) getProjectPathForGraphQL(project *gitlab.Project) (string, error) { + if project == nil { + return "", fmt.Errorf("source project is nil") + } + + if project.PathWithNamespace != "" { + return project.PathWithNamespace, nil + } + + if project.ID == 0 { + return "", fmt.Errorf("source project has no path and no id") + } + + reloaded, _, err := c.Projects.GetProject(project.ID, nil) + if err != nil { + return "", fmt.Errorf("could not load source project path for GraphQL: %w", err) + } + + if reloaded.PathWithNamespace == "" { + return "", fmt.Errorf("source project path is empty for project %d", project.ID) + } + + return reloaded.PathWithNamespace, nil +} diff --git a/gitlab/issues_contract_test.go b/gitlab/issues_contract_test.go index 5b60fdf..ee7149f 100644 --- a/gitlab/issues_contract_test.go +++ b/gitlab/issues_contract_test.go @@ -1,6 +1,7 @@ package gitlab import ( + "encoding/json" "net/http" "strings" "testing" @@ -99,21 +100,25 @@ func TestReplicateIssue_Success(t *testing.T) { source := &gitlabapi.Project{ID: 1, PathWithNamespace: "mpd/startercode/blatt-01"} target := &gitlabapi.Project{ID: 2, PathWithNamespace: "mpd/ss26/blatt-01/team1"} - if err := client.replicateIssue(source, target, 7); err != nil { + if _, err := client.replicateIssue(source, target, 7, false); err != nil { t.Fatalf("replicateIssue() error = %v", err) } } func TestReplicateIssue_GetIssueFails(t *testing.T) { client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/1/issues/7" { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + return + } w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) }) - source := &gitlabapi.Project{ID: 1} + source := &gitlabapi.Project{ID: 1, PathWithNamespace: "mpd/startercode/blatt-01"} target := &gitlabapi.Project{ID: 2} - err := client.replicateIssue(source, target, 7) + _, err := client.replicateIssue(source, target, 7, false) if err == nil { t.Fatal("expected error when loading issue fails") } @@ -132,11 +137,68 @@ func TestReplicateIssue_CreateIssueFails(t *testing.T) { } }) - source := &gitlabapi.Project{ID: 1} + source := &gitlabapi.Project{ID: 1, PathWithNamespace: "mpd/startercode/blatt-01"} target := &gitlabapi.Project{ID: 2} - err := client.replicateIssue(source, target, 7) + _, err := client.replicateIssue(source, target, 7, false) if err == nil { t.Fatal("expected error when creating issue fails") } } + +func TestResolveIssueNumbersForReplication_WithChildTasks(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v4/projects/1/issues/") { + if strings.HasSuffix(r.URL.Path, "/2") { + _, _ = w.Write([]byte(`{"id":2002,"iid":2,"title":"Aufgabenstellung","description":"Root"}`)) + return + } + if strings.HasSuffix(r.URL.Path, "/5") { + _, _ = w.Write([]byte(`{"id":2005,"iid":5,"title":"Teilaufgabe 1","description":"Child 1"}`)) + return + } + if strings.HasSuffix(r.URL.Path, "/6") { + _, _ = w.Write([]byte(`{"id":2006,"iid":6,"title":"Teilaufgabe 2","description":"Child 2"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + return + } + + if r.Method != http.MethodPost || r.URL.Path != "/api/graphql" { + w.WriteHeader(http.StatusNotFound) + return + } + + var req struct { + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + iid, _ := req.Variables["iid"].(string) + switch iid { + case "2": + _, _ = w.Write([]byte(`{"data":{"project":{"issue":{"workItem":{"widgets":[{"children":{"nodes":[{"iid":"5"},{"iid":"6"}]}}]}}}}}`)) + return + case "5", "6": + _, _ = w.Write([]byte(`{"data":{"project":{"issue":{"workItem":{"widgets":[]}}}}}`)) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + source := &gitlabapi.Project{ID: 1, PathWithNamespace: "mpd/startercode/blatt-07"} + + numbers, err := client.resolveIssueNumbersForReplication(source, []int{2}, true) + if err != nil { + t.Fatalf("resolveIssueNumbersForReplication() error = %v", err) + } + + if len(numbers) != 3 || numbers[0] != 2 || numbers[1] != 5 || numbers[2] != 6 { + t.Fatalf("resolved issue numbers = %#v, want [2 5 6]", numbers) + } +}