From c4c1f8520ea279358c67532ee79948aba857f592 Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Thu, 28 Aug 2025 12:23:46 -0700 Subject: [PATCH 1/8] Add updating issue links --- client-go-request.md | 107 ++++++++ .../commands/issue/update/issue_update.go | 69 +++++ .../issue/update/issue_update_test.go | 65 +++++ issue-update-links-finish.md | 247 ++++++++++++++++++ issue-update-links.md | 71 +++++ 5 files changed, 559 insertions(+) create mode 100644 client-go-request.md create mode 100644 internal/commands/issue/update/issue_update_test.go create mode 100644 issue-update-links-finish.md create mode 100644 issue-update-links.md diff --git a/client-go-request.md b/client-go-request.md new file mode 100644 index 000000000..9bd040394 --- /dev/null +++ b/client-go-request.md @@ -0,0 +1,107 @@ +# GitLab Client-Go Enhancement Request: Issue Links Management + +## Current State + +The GitLab client-go library currently provides limited support for issue links management: + +### What Works Now +- **CreateIssueLink**: `client.IssueLinks.CreateIssueLink(projectID, issueIID, options)` + - Creates a new link between issues + - Takes `CreateIssueLinkOptions` with `TargetIssueIID` and `LinkType` + - Returns `*gitlab.IssueLink` containing source and target issue information + +### What's Missing +The library lacks methods to: +1. **List existing issue links** for a given issue +2. **Delete specific issue links** by link ID + +## Required Enhancements + +### 1. List Issue Links Method + +**Method Signature Needed:** +```go +func (s *IssueLinksService) ListIssueLinks(pid interface{}, issue int, opt *ListIssueLinkOptions, options ...RequestOptionFunc) ([]*IssueLink, *Response, error) +``` + +**Options Structure:** +```go +type ListIssueLinkOptions struct { + ListOptions +} +``` + +**What it should do:** +- Retrieve all issue links for a specific issue +- Return an array of `IssueLink` objects +- Each `IssueLink` should contain: + - `ID`: The unique identifier of the link (needed for deletion) + - `SourceIssue`: The issue that contains the link + - `TargetIssue`: The issue being linked to + - `LinkType`: The relationship type ("relates_to", "blocks", "blocked_by") + +**GitLab API Endpoint:** +- `GET /projects/:id/issues/:issue_iid/links` +- Documentation: https://docs.gitlab.com/ee/api/issue_links.html#list-issue-links + +### 2. Delete Issue Link Method + +**Method Signature Needed:** +```go +func (s *IssueLinksService) DeleteIssueLink(pid interface{}, issue int, issueLinkID int, options ...RequestOptionFunc) (*Response, error) +``` + +**What it should do:** +- Delete a specific issue link by its ID +- Take the project ID, source issue IID, and the link ID +- Return only a response (no data payload needed) + +**GitLab API Endpoint:** +- `DELETE /projects/:id/issues/:issue_iid/links/:issue_link_id` +- Documentation: https://docs.gitlab.com/ee/api/issue_links.html#delete-an-issue-link + +## Data Structures + +### Current IssueLink Structure +The existing `IssueLink` struct should include (if not already present): + +```go +type IssueLink struct { + ID int `json:"id"` + SourceIssue *Issue `json:"source_issue"` + TargetIssue *Issue `json:"target_issue"` + LinkType string `json:"link_type"` +} +``` + +**Critical Field:** The `ID` field is essential for the delete operation. + +## Implementation Requirements + +### Service Interface +The `IssueLinksServiceInterface` should be updated to include: + +```go +type IssueLinksServiceInterface interface { + CreateIssueLink(pid interface{}, issue int, opt *CreateIssueLinkOptions, options ...RequestOptionFunc) (*IssueLink, *Response, error) + ListIssueLinks(pid interface{}, issue int, opt *ListIssueLinkOptions, options ...RequestOptionFunc) ([]*IssueLink, *Response, error) + DeleteIssueLink(pid interface{}, issue int, issueLinkID int, options ...RequestOptionFunc) (*Response, error) +} +``` + +## Use Case Context + +This enhancement is needed for the `glab issue update --unlink-issues` functionality, which requires: + +1. **Listing existing links** to find the link IDs for issues that need to be unlinked +2. **Deleting specific links** by their IDs + +The workflow would be: +1. User runs: `glab issue update 42 --unlink-issues 10,15` +2. Code calls `ListIssueLinks(project, 42, options)` to get all existing links +3. Code finds links where `TargetIssue.IID` matches 10 or 15 +4. Code calls `DeleteIssueLink(project, 42, linkID)` for each matching link + +## Priority + +**High Priority** - This blocks the completion of a user-requested feature that provides parity with the `glab issue create` command's linking capabilities. \ No newline at end of file diff --git a/internal/commands/issue/update/issue_update.go b/internal/commands/issue/update/issue_update.go index ca5449eb3..c6b8ca080 100644 --- a/internal/commands/issue/update/issue_update.go +++ b/internal/commands/issue/update/issue_update.go @@ -3,11 +3,14 @@ package update import ( "errors" "fmt" + "io" + "strconv" "strings" "gitlab.com/gitlab-org/cli/internal/api" "gitlab.com/gitlab-org/cli/internal/cmdutils" "gitlab.com/gitlab-org/cli/internal/commands/issue/issueutils" + "gitlab.com/gitlab-org/cli/internal/glrepo" "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" @@ -22,6 +25,8 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { Example: heredoc.Doc(` $ glab issue update 42 --label ui,ux $ glab issue update 42 --unlabel working + $ glab issue update 42 --linked-issues 10,15 --link-type blocks + $ glab issue update 42 --unlink-issues 10,15 `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -186,6 +191,13 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { return err } + // Handle issue linking after the main update + linkActions, err := handleIssueLinks(cmd, client, repo, issue, out) + if err != nil { + return err + } + actions = append(actions, linkActions...) + for _, s := range actions { fmt.Fprintln(out, c.GreenCheck(), s) } @@ -209,6 +221,63 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { issueUpdateCmd.Flags().Bool("unassign", false, "Unassign all users.") issueUpdateCmd.Flags().IntP("weight", "w", 0, "Set weight of the issue.") issueUpdateCmd.Flags().StringP("due-date", "", "", "A date in 'YYYY-MM-DD' format.") + issueUpdateCmd.Flags().IntSlice("linked-issues", []int{}, "The IIDs of issues to link to this issue.") + issueUpdateCmd.Flags().String("link-type", "relates_to", "Type for the issue link (relates_to, blocks, blocked_by).") + issueUpdateCmd.Flags().IntSlice("unlink-issues", []int{}, "The IIDs of issues to unlink from this issue.") return issueUpdateCmd } + +// handleIssueLinks manages linking and unlinking issues +func handleIssueLinks(cmd *cobra.Command, client *gitlab.Client, repo glrepo.Interface, issue *gitlab.Issue, out io.Writer) ([]string, error) { + var actions []string + + // Handle linking new issues + if cmd.Flags().Changed("linked-issues") { + linkedIssues, err := cmd.Flags().GetIntSlice("linked-issues") + if err != nil { + return nil, err + } + + linkType, err := cmd.Flags().GetString("link-type") + if err != nil { + return nil, err + } + + // Validate link type + validLinkTypes := map[string]bool{ + "relates_to": true, + "blocks": true, + "blocked_by": true, + } + if !validLinkTypes[linkType] { + return nil, fmt.Errorf("invalid link type %q. Valid types are: relates_to, blocks, blocked_by", linkType) + } + + for _, targetIssueIID := range linkedIssues { + fmt.Fprintf(out, "- Linking to issue #%d\n", targetIssueIID) + _, _, err := client.IssueLinks.CreateIssueLink(repo.FullName(), issue.IID, &gitlab.CreateIssueLinkOptions{ + TargetIssueIID: gitlab.Ptr(strconv.Itoa(targetIssueIID)), + LinkType: gitlab.Ptr(linkType), + }) + if err != nil { + return nil, fmt.Errorf("failed to link issue #%d: %w", targetIssueIID, err) + } + actions = append(actions, fmt.Sprintf("linked to issue #%d (%s)", targetIssueIID, linkType)) + } + } + + // Handle unlinking issues - for now, show a message that this feature is not yet implemented + if cmd.Flags().Changed("unlink-issues") { + unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") + if err != nil { + return nil, err + } + + if len(unlinkIssues) > 0 { + return nil, fmt.Errorf("unlinking issues is not yet implemented. Please use the GitLab web interface to remove issue links") + } + } + + return actions, nil +} diff --git a/internal/commands/issue/update/issue_update_test.go b/internal/commands/issue/update/issue_update_test.go new file mode 100644 index 000000000..54353e2ac --- /dev/null +++ b/internal/commands/issue/update/issue_update_test.go @@ -0,0 +1,65 @@ +package update + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/cmdutils" + "gitlab.com/gitlab-org/cli/internal/config" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" +) + +func TestNewCmdUpdate_Flags(t *testing.T) { + cfg, err := config.Init() + assert.NoError(t, err) + + ios, _, _, _ := cmdtest.TestIOStreams() + f := cmdutils.NewFactory(ios, false, cfg, api.BuildInfo{}) + + cmd := NewCmdUpdate(f) + + // Test that new flags are available + linkedIssuesFlag := cmd.Flags().Lookup("linked-issues") + assert.NotNil(t, linkedIssuesFlag) + + linkTypeFlag := cmd.Flags().Lookup("link-type") + assert.NotNil(t, linkTypeFlag) + + unlinkIssuesFlag := cmd.Flags().Lookup("unlink-issues") + assert.NotNil(t, unlinkIssuesFlag) + + // Test default values + linkType, err := cmd.Flags().GetString("link-type") + assert.NoError(t, err) + assert.Equal(t, "relates_to", linkType) + + linkedIssues, err := cmd.Flags().GetIntSlice("linked-issues") + assert.NoError(t, err) + assert.Empty(t, linkedIssues) + + unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") + assert.NoError(t, err) + assert.Empty(t, unlinkIssues) +} + +func TestHandleIssueLinks_ValidLinkType(t *testing.T) { + cfg, err := config.Init() + assert.NoError(t, err) + + ios, _, _, _ := cmdtest.TestIOStreams() + f := cmdutils.NewFactory(ios, false, cfg, api.BuildInfo{}) + + cmd := NewCmdUpdate(f) + + // Test valid link types + validTypes := []string{"relates_to", "blocks", "blocked_by"} + for _, linkType := range validTypes { + err := cmd.Flags().Set("link-type", linkType) + assert.NoError(t, err) + + value, err := cmd.Flags().GetString("link-type") + assert.NoError(t, err) + assert.Equal(t, linkType, value) + } +} diff --git a/issue-update-links-finish.md b/issue-update-links-finish.md new file mode 100644 index 000000000..a501ea735 --- /dev/null +++ b/issue-update-links-finish.md @@ -0,0 +1,247 @@ +# Instructions: Complete Issue Unlinking Implementation + +## Prerequisites +Ensure the GitLab client-go library has been updated with: +- `ListIssueLinks()` method +- `DeleteIssueLink()` method +- `IssueLink` struct with `ID` field + +## Implementation Steps + +### 1. Update the handleIssueLinks Function + +**File:** `internal/commands/issue/update/issue_update.go` + +**Location:** Replace the current unlinking section (around line 270-280) in the `handleIssueLinks` function. + +**Current Code to Replace:** +```go +// Handle unlinking issues - for now, show a message that this feature is not yet implemented +if cmd.Flags().Changed("unlink-issues") { + unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") + if err != nil { + return nil, err + } + + if len(unlinkIssues) > 0 { + return nil, fmt.Errorf("unlinking issues is not yet implemented. Please use the GitLab web interface to remove issue links") + } +} +``` + +**New Code:** +```go +// Handle unlinking issues +if cmd.Flags().Changed("unlink-issues") { + unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") + if err != nil { + return nil, err + } + + if len(unlinkIssues) > 0 { + // First, get all existing links for this issue + issueLinks, _, err := client.IssueLinks.ListIssueLinks(repo.FullName(), issue.IID, &gitlab.ListIssueLinkOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get existing issue links: %w", err) + } + + // Create a map of target IID to link ID for easy lookup + linkMap := make(map[int]int) + for _, link := range issueLinks { + if link.TargetIssue != nil { + linkMap[link.TargetIssue.IID] = link.ID + } + } + + for _, targetIssueIID := range unlinkIssues { + linkID, exists := linkMap[targetIssueIID] + if !exists { + return nil, fmt.Errorf("no link found to issue #%d", targetIssueIID) + } + + fmt.Fprintf(out, "- Unlinking from issue #%d\n", targetIssueIID) + _, err := client.IssueLinks.DeleteIssueLink(repo.FullName(), issue.IID, linkID) + if err != nil { + return nil, fmt.Errorf("failed to unlink issue #%d: %w", targetIssueIID, err) + } + actions = append(actions, fmt.Sprintf("unlinked from issue #%d", targetIssueIID)) + } + } +} +``` + +### 2. Add Import for ListIssueLinkOptions + +**File:** `internal/commands/issue/update/issue_update.go` + +Ensure the import section includes the GitLab client-go package (should already be there): +```go +gitlab "gitlab.com/gitlab-org/api/client-go" +``` + +### 3. Update Integration Tests + +**File:** `internal/commands/issue/update/issue_update_integration_test.go` + +**Add test cases** to the `testCases` slice (around line 80): + +```go +{ + Name: "Unlink issues", + Issue: fmt.Sprintf(`-R %s/cli-automated-testing/test 1 -t "New Title" --unlink-issues 10,15`, glTestHost), + ExpectedMsg: []string{ + "- Updating issue #1", + "✓ updated title to \"New Title\"", + "- Unlinking from issue #10", + "- Unlinking from issue #15", + "✓ unlinked from issue #10", + "✓ unlinked from issue #15", + "#1 New Title", + }, +}, +{ + Name: "Unlink non-existent link", + Issue: fmt.Sprintf(`-R %s/cli-automated-testing/test 1 --unlink-issues 999`, glTestHost), + ExpectedMsg: []string{"no link found to issue #999"}, + wantErr: true, +}, +``` + +**Add mock functions** after the existing `api.UpdateIssue` mock (around line 50): + +```go +// Mock ListIssueLinks +originalListIssueLinks := func(client *gitlab.Client, projectID any, issueIID int, opts *gitlab.ListIssueLinkOptions) ([]*gitlab.IssueLink, *gitlab.Response, error) { + // Mock some existing links for testing + return []*gitlab.IssueLink{ + { + ID: 1, + SourceIssue: testIssue, + TargetIssue: &gitlab.Issue{IID: 10}, + LinkType: "relates_to", + }, + { + ID: 2, + SourceIssue: testIssue, + TargetIssue: &gitlab.Issue{IID: 15}, + LinkType: "blocks", + }, + }, nil, nil +} + +// Mock DeleteIssueLink +originalDeleteIssueLink := func(client *gitlab.Client, projectID any, issueIID int, linkID int) (*gitlab.Response, error) { + // Simulate successful deletion + return nil, nil +} + +// Suppress unused variable warnings for now +_ = originalListIssueLinks +_ = originalDeleteIssueLink +``` + +### 4. Add Unit Tests + +**File:** `internal/commands/issue/update/issue_update_test.go` + +**Add new test function:** + +```go +func TestHandleIssueLinks_UnlinkValidation(t *testing.T) { + cfg, err := config.Init() + assert.NoError(t, err) + + ios, _, _, _ := cmdtest.TestIOStreams() + f := cmdutils.NewFactory(ios, false, cfg, api.BuildInfo{}) + + cmd := NewCmdUpdate(f) + + // Test setting unlink-issues flag + err = cmd.Flags().Set("unlink-issues", "10,15,20") + assert.NoError(t, err) + + unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") + assert.NoError(t, err) + assert.Equal(t, []int{10, 15, 20}, unlinkIssues) +} +``` + +### 5. Test the Implementation + +**Build and test:** +```bash +# Build to check for compilation errors +go build ./internal/commands/issue/update + +# Run unit tests +go test ./internal/commands/issue/update -v + +# Test help output includes unlinking +go run ./cmd/glab issue update --help +``` + +**Manual testing commands:** +```bash +# Test unlinking (will need real GitLab project with linked issues) +glab issue update 42 --unlink-issues 10,15 + +# Test error handling +glab issue update 42 --unlink-issues 999 # Should show "no link found" error +``` + +### 6. Update Documentation + +**File:** `internal/commands/issue/update/issue_update.go` + +The help examples should already include unlinking examples from the original implementation. Verify they're still present in the `Example:` section: + +```go +Example: heredoc.Doc(` + $ glab issue update 42 --label ui,ux + $ glab issue update 42 --unlabel working + $ glab issue update 42 --linked-issues 10,15 --link-type blocks + $ glab issue update 42 --unlink-issues 10,15 +`), +``` + +## Verification Checklist + +- [ ] Code compiles without errors +- [ ] Unit tests pass +- [ ] Help output shows unlinking flags and examples +- [ ] Error handling works for non-existent links +- [ ] Success messages appear for successful unlinking +- [ ] Integration tests pass (if GitLab test environment available) + +## Expected Behavior + +After implementation: + +1. **Successful unlinking:** + ```bash + $ glab issue update 42 --unlink-issues 10,15 + - Updating issue #42 + - Unlinking from issue #10 + - Unlinking from issue #15 + ✓ unlinked from issue #10 + ✓ unlinked from issue #15 + ``` + +2. **Error for non-existent link:** + ```bash + $ glab issue update 42 --unlink-issues 999 + Error: no link found to issue #999 + ``` + +3. **Combined operations:** + ```bash + $ glab issue update 42 --title "Updated" --linked-issues 20 --unlink-issues 10 + - Updating issue #42 + ✓ updated title to "Updated" + - Linking to issue #20 + - Unlinking from issue #10 + ✓ linked to issue #20 (relates_to) + ✓ unlinked from issue #10 + ``` + +This completes the issue linking/unlinking feature for `glab issue update`! \ No newline at end of file diff --git a/issue-update-links.md b/issue-update-links.md new file mode 100644 index 000000000..25ecb4017 --- /dev/null +++ b/issue-update-links.md @@ -0,0 +1,71 @@ +# Plan: Add Issue Linking Support to `glab issue update` + +## Analysis Summary +Currently, `glab issue update` only supports basic issue properties but not linking. Issue linking is only available during `glab issue create` using the GitLab IssueLinks API. + +## Required Changes + +### 1. Update CLI Flags (`issue_update.go:198-212`) +- Add `--linked-issues` flag (comma-separated list of IIDs) +- Add `--link-type` flag (defaults to "relates_to") +- Add `--unlink-issues` flag (comma-separated list of IIDs to remove) + +### 2. Update Command Logic (`issue_update.go:69-181`) +- Parse new linking flags +- Add linking logic after the main `UpdateIssue` call +- Use `apiClient.IssueLinks.CreateIssueLink()` for new links +- Use `apiClient.IssueLinks.DeleteIssueLink()` for unlinking +- Add appropriate action messages for user feedback + +### 3. Add Helper Functions +- Link management function similar to `postCreateActions` in create command +- Error handling for invalid IIDs and link operations +- Support for different link types: `relates_to`, `blocks`, `blocked_by` + +### 4. Update Tests (`issue_update_integration_test.go`) +- Add test cases for linking/unlinking scenarios +- Mock the IssueLinks API calls +- Test error conditions (invalid IIDs, API failures) + +### 5. Update Help Documentation +- Add examples showing linking usage +- Document available link types +- Show unlinking examples + +## Technical Implementation Details + +The implementation will follow the same pattern as the existing `issue create` command: +1. Parse flags during command execution +2. Perform the main issue update via `gitlab.UpdateIssueOptions` +3. Handle linking as a separate post-update operation using `gitlab.IssueLinks` API +4. Provide user feedback for each linking action + +## Files to Modify +- `internal/commands/issue/update/issue_update.go` (main implementation) +- `internal/commands/issue/update/issue_update_integration_test.go` (tests) + +This approach maintains consistency with existing glab patterns and leverages the same GitLab API calls already used in issue creation. + +## Example Usage (Proposed) + +```bash +# Link issue #42 to issues #10 and #15 with "blocks" relationship +glab issue update 42 --linked-issues 10,15 --link-type blocks + +# Remove links to issues #10 and #15 from issue #42 +glab issue update 42 --unlink-issues 10,15 + +# Add a "relates_to" link (default type) +glab issue update 42 --linked-issues 20 +``` + +## API Reference + +Based on the existing `issue create` implementation, the GitLab API calls used will be: + +- `apiClient.IssueLinks.CreateIssueLink(repo, issueIID, options)` +- `apiClient.IssueLinks.DeleteIssueLink(repo, issueIID, linkID)` (may need to list links first) + +Where `CreateIssueLinkOptions` includes: +- `TargetIssueIID`: The IID of the issue to link to +- `LinkType`: The type of relationship ("relates_to", "blocks", "blocked_by") \ No newline at end of file -- GitLab From 1ed67128932f94a9d98d194965e2018f3ef8f36f Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Thu, 28 Aug 2025 16:49:24 -0700 Subject: [PATCH 2/8] Implement unlinking issues --- .../commands/issue/update/issue_update.go | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/commands/issue/update/issue_update.go b/internal/commands/issue/update/issue_update.go index c6b8ca080..18293d1dc 100644 --- a/internal/commands/issue/update/issue_update.go +++ b/internal/commands/issue/update/issue_update.go @@ -267,7 +267,7 @@ func handleIssueLinks(cmd *cobra.Command, client *gitlab.Client, repo glrepo.Int } } - // Handle unlinking issues - for now, show a message that this feature is not yet implemented + // Handle unlinking issues if cmd.Flags().Changed("unlink-issues") { unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") if err != nil { @@ -275,7 +275,31 @@ func handleIssueLinks(cmd *cobra.Command, client *gitlab.Client, repo glrepo.Int } if len(unlinkIssues) > 0 { - return nil, fmt.Errorf("unlinking issues is not yet implemented. Please use the GitLab web interface to remove issue links") + // First, get all existing relations for this issue + relations, _, err := client.IssueLinks.ListIssueRelations(repo.FullName(), issue.IID) + if err != nil { + return nil, fmt.Errorf("failed to get existing issue relations: %w", err) + } + + // Create a map of target IID to link ID for easy lookup + linkMap := make(map[int]int) + for _, relation := range relations { + linkMap[relation.IID] = relation.IssueLinkID + } + + for _, targetIssueIID := range unlinkIssues { + linkID, exists := linkMap[targetIssueIID] + if !exists { + return nil, fmt.Errorf("no link found to issue #%d", targetIssueIID) + } + + fmt.Fprintf(out, "- Unlinking from issue #%d\n", targetIssueIID) + _, _, err := client.IssueLinks.DeleteIssueLink(repo.FullName(), issue.IID, linkID) + if err != nil { + return nil, fmt.Errorf("failed to unlink issue #%d: %w", targetIssueIID, err) + } + actions = append(actions, fmt.Sprintf("unlinked from issue #%d", targetIssueIID)) + } } } -- GitLab From ab1d839c169a7864db2345bb518bdbbc46764b4b Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Thu, 28 Aug 2025 17:03:27 -0700 Subject: [PATCH 3/8] Do not require issues changes for link modification --- internal/commands/issue/update/issue_update.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/commands/issue/update/issue_update.go b/internal/commands/issue/update/issue_update.go index 18293d1dc..76da10b19 100644 --- a/internal/commands/issue/update/issue_update.go +++ b/internal/commands/issue/update/issue_update.go @@ -184,11 +184,14 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { l.DueDate = gitlab.Ptr(dueDate) } - fmt.Fprintf(out, "- Updating issue #%d\n", issue.IID) + // Only call UpdateIssue API if there are actual issue property changes + if len(actions) > 0 { + fmt.Fprintf(out, "- Updating issue #%d\n", issue.IID) - issue, err = api.UpdateIssue(client, repo.FullName(), issue.IID, l) - if err != nil { - return err + issue, err = api.UpdateIssue(client, repo.FullName(), issue.IID, l) + if err != nil { + return err + } } // Handle issue linking after the main update @@ -196,6 +199,12 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { if err != nil { return err } + + // Show "Updating issue" message if we only have linking operations + if len(actions) == 0 && len(linkActions) > 0 { + fmt.Fprintf(out, "- Updating issue #%d\n", issue.IID) + } + actions = append(actions, linkActions...) for _, s := range actions { -- GitLab From 5ec79ca4c25b99fa0891d18277e2d92f0c2ebe75 Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Fri, 29 Aug 2025 09:50:23 -0700 Subject: [PATCH 4/8] Remove instructions --- client-go-request.md | 107 --------------- issue-update-links-finish.md | 247 ----------------------------------- issue-update-links.md | 71 ---------- 3 files changed, 425 deletions(-) delete mode 100644 client-go-request.md delete mode 100644 issue-update-links-finish.md delete mode 100644 issue-update-links.md diff --git a/client-go-request.md b/client-go-request.md deleted file mode 100644 index 9bd040394..000000000 --- a/client-go-request.md +++ /dev/null @@ -1,107 +0,0 @@ -# GitLab Client-Go Enhancement Request: Issue Links Management - -## Current State - -The GitLab client-go library currently provides limited support for issue links management: - -### What Works Now -- **CreateIssueLink**: `client.IssueLinks.CreateIssueLink(projectID, issueIID, options)` - - Creates a new link between issues - - Takes `CreateIssueLinkOptions` with `TargetIssueIID` and `LinkType` - - Returns `*gitlab.IssueLink` containing source and target issue information - -### What's Missing -The library lacks methods to: -1. **List existing issue links** for a given issue -2. **Delete specific issue links** by link ID - -## Required Enhancements - -### 1. List Issue Links Method - -**Method Signature Needed:** -```go -func (s *IssueLinksService) ListIssueLinks(pid interface{}, issue int, opt *ListIssueLinkOptions, options ...RequestOptionFunc) ([]*IssueLink, *Response, error) -``` - -**Options Structure:** -```go -type ListIssueLinkOptions struct { - ListOptions -} -``` - -**What it should do:** -- Retrieve all issue links for a specific issue -- Return an array of `IssueLink` objects -- Each `IssueLink` should contain: - - `ID`: The unique identifier of the link (needed for deletion) - - `SourceIssue`: The issue that contains the link - - `TargetIssue`: The issue being linked to - - `LinkType`: The relationship type ("relates_to", "blocks", "blocked_by") - -**GitLab API Endpoint:** -- `GET /projects/:id/issues/:issue_iid/links` -- Documentation: https://docs.gitlab.com/ee/api/issue_links.html#list-issue-links - -### 2. Delete Issue Link Method - -**Method Signature Needed:** -```go -func (s *IssueLinksService) DeleteIssueLink(pid interface{}, issue int, issueLinkID int, options ...RequestOptionFunc) (*Response, error) -``` - -**What it should do:** -- Delete a specific issue link by its ID -- Take the project ID, source issue IID, and the link ID -- Return only a response (no data payload needed) - -**GitLab API Endpoint:** -- `DELETE /projects/:id/issues/:issue_iid/links/:issue_link_id` -- Documentation: https://docs.gitlab.com/ee/api/issue_links.html#delete-an-issue-link - -## Data Structures - -### Current IssueLink Structure -The existing `IssueLink` struct should include (if not already present): - -```go -type IssueLink struct { - ID int `json:"id"` - SourceIssue *Issue `json:"source_issue"` - TargetIssue *Issue `json:"target_issue"` - LinkType string `json:"link_type"` -} -``` - -**Critical Field:** The `ID` field is essential for the delete operation. - -## Implementation Requirements - -### Service Interface -The `IssueLinksServiceInterface` should be updated to include: - -```go -type IssueLinksServiceInterface interface { - CreateIssueLink(pid interface{}, issue int, opt *CreateIssueLinkOptions, options ...RequestOptionFunc) (*IssueLink, *Response, error) - ListIssueLinks(pid interface{}, issue int, opt *ListIssueLinkOptions, options ...RequestOptionFunc) ([]*IssueLink, *Response, error) - DeleteIssueLink(pid interface{}, issue int, issueLinkID int, options ...RequestOptionFunc) (*Response, error) -} -``` - -## Use Case Context - -This enhancement is needed for the `glab issue update --unlink-issues` functionality, which requires: - -1. **Listing existing links** to find the link IDs for issues that need to be unlinked -2. **Deleting specific links** by their IDs - -The workflow would be: -1. User runs: `glab issue update 42 --unlink-issues 10,15` -2. Code calls `ListIssueLinks(project, 42, options)` to get all existing links -3. Code finds links where `TargetIssue.IID` matches 10 or 15 -4. Code calls `DeleteIssueLink(project, 42, linkID)` for each matching link - -## Priority - -**High Priority** - This blocks the completion of a user-requested feature that provides parity with the `glab issue create` command's linking capabilities. \ No newline at end of file diff --git a/issue-update-links-finish.md b/issue-update-links-finish.md deleted file mode 100644 index a501ea735..000000000 --- a/issue-update-links-finish.md +++ /dev/null @@ -1,247 +0,0 @@ -# Instructions: Complete Issue Unlinking Implementation - -## Prerequisites -Ensure the GitLab client-go library has been updated with: -- `ListIssueLinks()` method -- `DeleteIssueLink()` method -- `IssueLink` struct with `ID` field - -## Implementation Steps - -### 1. Update the handleIssueLinks Function - -**File:** `internal/commands/issue/update/issue_update.go` - -**Location:** Replace the current unlinking section (around line 270-280) in the `handleIssueLinks` function. - -**Current Code to Replace:** -```go -// Handle unlinking issues - for now, show a message that this feature is not yet implemented -if cmd.Flags().Changed("unlink-issues") { - unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") - if err != nil { - return nil, err - } - - if len(unlinkIssues) > 0 { - return nil, fmt.Errorf("unlinking issues is not yet implemented. Please use the GitLab web interface to remove issue links") - } -} -``` - -**New Code:** -```go -// Handle unlinking issues -if cmd.Flags().Changed("unlink-issues") { - unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") - if err != nil { - return nil, err - } - - if len(unlinkIssues) > 0 { - // First, get all existing links for this issue - issueLinks, _, err := client.IssueLinks.ListIssueLinks(repo.FullName(), issue.IID, &gitlab.ListIssueLinkOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get existing issue links: %w", err) - } - - // Create a map of target IID to link ID for easy lookup - linkMap := make(map[int]int) - for _, link := range issueLinks { - if link.TargetIssue != nil { - linkMap[link.TargetIssue.IID] = link.ID - } - } - - for _, targetIssueIID := range unlinkIssues { - linkID, exists := linkMap[targetIssueIID] - if !exists { - return nil, fmt.Errorf("no link found to issue #%d", targetIssueIID) - } - - fmt.Fprintf(out, "- Unlinking from issue #%d\n", targetIssueIID) - _, err := client.IssueLinks.DeleteIssueLink(repo.FullName(), issue.IID, linkID) - if err != nil { - return nil, fmt.Errorf("failed to unlink issue #%d: %w", targetIssueIID, err) - } - actions = append(actions, fmt.Sprintf("unlinked from issue #%d", targetIssueIID)) - } - } -} -``` - -### 2. Add Import for ListIssueLinkOptions - -**File:** `internal/commands/issue/update/issue_update.go` - -Ensure the import section includes the GitLab client-go package (should already be there): -```go -gitlab "gitlab.com/gitlab-org/api/client-go" -``` - -### 3. Update Integration Tests - -**File:** `internal/commands/issue/update/issue_update_integration_test.go` - -**Add test cases** to the `testCases` slice (around line 80): - -```go -{ - Name: "Unlink issues", - Issue: fmt.Sprintf(`-R %s/cli-automated-testing/test 1 -t "New Title" --unlink-issues 10,15`, glTestHost), - ExpectedMsg: []string{ - "- Updating issue #1", - "✓ updated title to \"New Title\"", - "- Unlinking from issue #10", - "- Unlinking from issue #15", - "✓ unlinked from issue #10", - "✓ unlinked from issue #15", - "#1 New Title", - }, -}, -{ - Name: "Unlink non-existent link", - Issue: fmt.Sprintf(`-R %s/cli-automated-testing/test 1 --unlink-issues 999`, glTestHost), - ExpectedMsg: []string{"no link found to issue #999"}, - wantErr: true, -}, -``` - -**Add mock functions** after the existing `api.UpdateIssue` mock (around line 50): - -```go -// Mock ListIssueLinks -originalListIssueLinks := func(client *gitlab.Client, projectID any, issueIID int, opts *gitlab.ListIssueLinkOptions) ([]*gitlab.IssueLink, *gitlab.Response, error) { - // Mock some existing links for testing - return []*gitlab.IssueLink{ - { - ID: 1, - SourceIssue: testIssue, - TargetIssue: &gitlab.Issue{IID: 10}, - LinkType: "relates_to", - }, - { - ID: 2, - SourceIssue: testIssue, - TargetIssue: &gitlab.Issue{IID: 15}, - LinkType: "blocks", - }, - }, nil, nil -} - -// Mock DeleteIssueLink -originalDeleteIssueLink := func(client *gitlab.Client, projectID any, issueIID int, linkID int) (*gitlab.Response, error) { - // Simulate successful deletion - return nil, nil -} - -// Suppress unused variable warnings for now -_ = originalListIssueLinks -_ = originalDeleteIssueLink -``` - -### 4. Add Unit Tests - -**File:** `internal/commands/issue/update/issue_update_test.go` - -**Add new test function:** - -```go -func TestHandleIssueLinks_UnlinkValidation(t *testing.T) { - cfg, err := config.Init() - assert.NoError(t, err) - - ios, _, _, _ := cmdtest.TestIOStreams() - f := cmdutils.NewFactory(ios, false, cfg, api.BuildInfo{}) - - cmd := NewCmdUpdate(f) - - // Test setting unlink-issues flag - err = cmd.Flags().Set("unlink-issues", "10,15,20") - assert.NoError(t, err) - - unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") - assert.NoError(t, err) - assert.Equal(t, []int{10, 15, 20}, unlinkIssues) -} -``` - -### 5. Test the Implementation - -**Build and test:** -```bash -# Build to check for compilation errors -go build ./internal/commands/issue/update - -# Run unit tests -go test ./internal/commands/issue/update -v - -# Test help output includes unlinking -go run ./cmd/glab issue update --help -``` - -**Manual testing commands:** -```bash -# Test unlinking (will need real GitLab project with linked issues) -glab issue update 42 --unlink-issues 10,15 - -# Test error handling -glab issue update 42 --unlink-issues 999 # Should show "no link found" error -``` - -### 6. Update Documentation - -**File:** `internal/commands/issue/update/issue_update.go` - -The help examples should already include unlinking examples from the original implementation. Verify they're still present in the `Example:` section: - -```go -Example: heredoc.Doc(` - $ glab issue update 42 --label ui,ux - $ glab issue update 42 --unlabel working - $ glab issue update 42 --linked-issues 10,15 --link-type blocks - $ glab issue update 42 --unlink-issues 10,15 -`), -``` - -## Verification Checklist - -- [ ] Code compiles without errors -- [ ] Unit tests pass -- [ ] Help output shows unlinking flags and examples -- [ ] Error handling works for non-existent links -- [ ] Success messages appear for successful unlinking -- [ ] Integration tests pass (if GitLab test environment available) - -## Expected Behavior - -After implementation: - -1. **Successful unlinking:** - ```bash - $ glab issue update 42 --unlink-issues 10,15 - - Updating issue #42 - - Unlinking from issue #10 - - Unlinking from issue #15 - ✓ unlinked from issue #10 - ✓ unlinked from issue #15 - ``` - -2. **Error for non-existent link:** - ```bash - $ glab issue update 42 --unlink-issues 999 - Error: no link found to issue #999 - ``` - -3. **Combined operations:** - ```bash - $ glab issue update 42 --title "Updated" --linked-issues 20 --unlink-issues 10 - - Updating issue #42 - ✓ updated title to "Updated" - - Linking to issue #20 - - Unlinking from issue #10 - ✓ linked to issue #20 (relates_to) - ✓ unlinked from issue #10 - ``` - -This completes the issue linking/unlinking feature for `glab issue update`! \ No newline at end of file diff --git a/issue-update-links.md b/issue-update-links.md deleted file mode 100644 index 25ecb4017..000000000 --- a/issue-update-links.md +++ /dev/null @@ -1,71 +0,0 @@ -# Plan: Add Issue Linking Support to `glab issue update` - -## Analysis Summary -Currently, `glab issue update` only supports basic issue properties but not linking. Issue linking is only available during `glab issue create` using the GitLab IssueLinks API. - -## Required Changes - -### 1. Update CLI Flags (`issue_update.go:198-212`) -- Add `--linked-issues` flag (comma-separated list of IIDs) -- Add `--link-type` flag (defaults to "relates_to") -- Add `--unlink-issues` flag (comma-separated list of IIDs to remove) - -### 2. Update Command Logic (`issue_update.go:69-181`) -- Parse new linking flags -- Add linking logic after the main `UpdateIssue` call -- Use `apiClient.IssueLinks.CreateIssueLink()` for new links -- Use `apiClient.IssueLinks.DeleteIssueLink()` for unlinking -- Add appropriate action messages for user feedback - -### 3. Add Helper Functions -- Link management function similar to `postCreateActions` in create command -- Error handling for invalid IIDs and link operations -- Support for different link types: `relates_to`, `blocks`, `blocked_by` - -### 4. Update Tests (`issue_update_integration_test.go`) -- Add test cases for linking/unlinking scenarios -- Mock the IssueLinks API calls -- Test error conditions (invalid IIDs, API failures) - -### 5. Update Help Documentation -- Add examples showing linking usage -- Document available link types -- Show unlinking examples - -## Technical Implementation Details - -The implementation will follow the same pattern as the existing `issue create` command: -1. Parse flags during command execution -2. Perform the main issue update via `gitlab.UpdateIssueOptions` -3. Handle linking as a separate post-update operation using `gitlab.IssueLinks` API -4. Provide user feedback for each linking action - -## Files to Modify -- `internal/commands/issue/update/issue_update.go` (main implementation) -- `internal/commands/issue/update/issue_update_integration_test.go` (tests) - -This approach maintains consistency with existing glab patterns and leverages the same GitLab API calls already used in issue creation. - -## Example Usage (Proposed) - -```bash -# Link issue #42 to issues #10 and #15 with "blocks" relationship -glab issue update 42 --linked-issues 10,15 --link-type blocks - -# Remove links to issues #10 and #15 from issue #42 -glab issue update 42 --unlink-issues 10,15 - -# Add a "relates_to" link (default type) -glab issue update 42 --linked-issues 20 -``` - -## API Reference - -Based on the existing `issue create` implementation, the GitLab API calls used will be: - -- `apiClient.IssueLinks.CreateIssueLink(repo, issueIID, options)` -- `apiClient.IssueLinks.DeleteIssueLink(repo, issueIID, linkID)` (may need to list links first) - -Where `CreateIssueLinkOptions` includes: -- `TargetIssueIID`: The IID of the issue to link to -- `LinkType`: The type of relationship ("relates_to", "blocks", "blocked_by") \ No newline at end of file -- GitLab From 98b7676d6176a21c3e85851cf66d6005509147bf Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Fri, 29 Aug 2025 10:23:27 -0700 Subject: [PATCH 5/8] Add tests --- go.mod | 1 + .../commands/issue/update/issue_update.go | 14 +- .../issue/update/issue_update_test.go | 191 +++++++++++++++++- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 102e39e2f..2877313d3 100644 --- a/go.mod +++ b/go.mod @@ -108,6 +108,7 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/internal/commands/issue/update/issue_update.go b/internal/commands/issue/update/issue_update.go index 76da10b19..9f5aeec06 100644 --- a/internal/commands/issue/update/issue_update.go +++ b/internal/commands/issue/update/issue_update.go @@ -264,6 +264,11 @@ func handleIssueLinks(cmd *cobra.Command, client *gitlab.Client, repo glrepo.Int } for _, targetIssueIID := range linkedIssues { + // Validate that we're not trying to link to ourselves + if targetIssueIID == issue.IID { + return nil, fmt.Errorf("cannot link issue to itself (#%d)", targetIssueIID) + } + fmt.Fprintf(out, "- Linking to issue #%d\n", targetIssueIID) _, _, err := client.IssueLinks.CreateIssueLink(repo.FullName(), issue.IID, &gitlab.CreateIssueLinkOptions{ TargetIssueIID: gitlab.Ptr(strconv.Itoa(targetIssueIID)), @@ -293,7 +298,14 @@ func handleIssueLinks(cmd *cobra.Command, client *gitlab.Client, repo glrepo.Int // Create a map of target IID to link ID for easy lookup linkMap := make(map[int]int) for _, relation := range relations { - linkMap[relation.IID] = relation.IssueLinkID + // Map the target issue IID (the "other" issue in the relation) + targetIID := relation.IID + if relation.IID == issue.IID { + // If this relation's IID is our current issue, the target is the other field + // This logic may need adjustment based on the actual API response structure + continue // Skip self-references or handle appropriately + } + linkMap[targetIID] = relation.IssueLinkID } for _, targetIssueIID := range unlinkIssues { diff --git a/internal/commands/issue/update/issue_update_test.go b/internal/commands/issue/update/issue_update_test.go index 54353e2ac..9c7a947a5 100644 --- a/internal/commands/issue/update/issue_update_test.go +++ b/internal/commands/issue/update/issue_update_test.go @@ -1,6 +1,7 @@ package update import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -43,7 +44,7 @@ func TestNewCmdUpdate_Flags(t *testing.T) { assert.Empty(t, unlinkIssues) } -func TestHandleIssueLinks_ValidLinkType(t *testing.T) { +func TestLinkTypeFlag_SetAndGet(t *testing.T) { cfg, err := config.Init() assert.NoError(t, err) @@ -63,3 +64,191 @@ func TestHandleIssueLinks_ValidLinkType(t *testing.T) { assert.Equal(t, linkType, value) } } + +func TestLinkTypeValidation(t *testing.T) { + tests := []struct { + name string + linkType string + expectValid bool + }{ + { + name: "valid relates_to", + linkType: "relates_to", + expectValid: true, + }, + { + name: "valid blocks", + linkType: "blocks", + expectValid: true, + }, + { + name: "valid blocked_by", + linkType: "blocked_by", + expectValid: true, + }, + { + name: "invalid link type", + linkType: "invalid_type", + expectValid: false, + }, + { + name: "empty link type", + linkType: "", + expectValid: false, + }, + { + name: "case sensitive validation", + linkType: "RELATES_TO", + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the validation logic directly + validLinkTypes := map[string]bool{ + "relates_to": true, + "blocks": true, + "blocked_by": true, + } + + isValid := validLinkTypes[tt.linkType] + assert.Equal(t, tt.expectValid, isValid, "Link type %q validation failed", tt.linkType) + }) + } +} + +func TestSelfReferenceValidation(t *testing.T) { + tests := []struct { + name string + issueIID int + linkedIssueIID int + expectError bool + }{ + { + name: "different issues - valid", + issueIID: 1, + linkedIssueIID: 2, + expectError: false, + }, + { + name: "same issue - invalid", + issueIID: 42, + linkedIssueIID: 42, + expectError: true, + }, + { + name: "zero IID edge case", + issueIID: 0, + linkedIssueIID: 0, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the self-reference validation logic + isSelfReference := tt.issueIID == tt.linkedIssueIID + assert.Equal(t, tt.expectError, isSelfReference, "Self-reference validation failed") + }) + } +} + +func TestRelationMappingLogic(t *testing.T) { + tests := []struct { + name string + currentIssueIID int + relations []mockRelation + expectedMap map[int]int + }{ + { + name: "empty relations", + currentIssueIID: 1, + relations: []mockRelation{}, + expectedMap: map[int]int{}, + }, + { + name: "relations with self-reference", + currentIssueIID: 1, + relations: []mockRelation{ + {IID: 1, IssueLinkID: 100}, // Self-reference, should be skipped + {IID: 2, IssueLinkID: 200}, // Valid relation + }, + expectedMap: map[int]int{2: 200}, + }, + { + name: "multiple valid relations", + currentIssueIID: 1, + relations: []mockRelation{ + {IID: 10, IssueLinkID: 100}, + {IID: 15, IssueLinkID: 150}, + {IID: 20, IssueLinkID: 200}, + }, + expectedMap: map[int]int{10: 100, 15: 150, 20: 200}, + }, + { + name: "only self-references", + currentIssueIID: 5, + relations: []mockRelation{ + {IID: 5, IssueLinkID: 100}, + {IID: 5, IssueLinkID: 200}, + }, + expectedMap: map[int]int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the relation mapping logic from handleIssueLinks + linkMap := make(map[int]int) + for _, relation := range tt.relations { + targetIID := relation.IID + if relation.IID == tt.currentIssueIID { + // Skip self-references + continue + } + linkMap[targetIID] = relation.IssueLinkID + } + + assert.Equal(t, tt.expectedMap, linkMap, "Relation mapping failed") + }) + } +} + +func TestStringConversionLogic(t *testing.T) { + tests := []struct { + name string + input int + expected string + }{ + { + name: "positive integer", + input: 42, + expected: "42", + }, + { + name: "zero", + input: 0, + expected: "0", + }, + { + name: "large number", + input: 999999, + expected: "999999", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the string conversion logic used in TargetIssueIID + result := fmt.Sprintf("%d", tt.input) + assert.Equal(t, tt.expected, result, "String conversion failed") + }) + } +} + +// Helper type for testing relation mapping logic +type mockRelation struct { + IID int + IssueLinkID int +} -- GitLab From 07da6561f0a6bab612b2d084ef1b4ba27f137b4d Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Fri, 29 Aug 2025 10:25:28 -0700 Subject: [PATCH 6/8] Update docs --- docs/source/issue/update.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/source/issue/update.md b/docs/source/issue/update.md index 66bfcbfe6..0c6ab9bf7 100644 --- a/docs/source/issue/update.md +++ b/docs/source/issue/update.md @@ -22,6 +22,8 @@ glab issue update [flags] ```console $ glab issue update 42 --label ui,ux $ glab issue update 42 --unlabel working +$ glab issue update 42 --linked-issues 10,15 --link-type blocks +$ glab issue update 42 --unlink-issues 10,15 ``` @@ -33,12 +35,15 @@ $ glab issue update 42 --unlabel working -d, --description string Issue description. Set to "-" to open an editor. --due-date string A date in 'YYYY-MM-DD' format. -l, --label strings Add labels. + --link-type string Type for the issue link (relates_to, blocks, blocked_by). (default "relates_to") + --linked-issues ints The IIDs of issues to link to this issue. --lock-discussion Lock discussion on issue. -m, --milestone string Title of the milestone to assign Set to "" or 0 to unassign. -p, --public Make issue public. -t, --title string Title of issue. --unassign Unassign all users. -u, --unlabel strings Remove labels. + --unlink-issues ints The IIDs of issues to unlink from this issue. --unlock-discussion Unlock discussion on issue. -w, --weight int Set weight of the issue. ``` -- GitLab From 4b8cdb7adcb600e45011a5fffb5fd2fd8c983c81 Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Fri, 29 Aug 2025 13:24:26 -0700 Subject: [PATCH 7/8] Fix link types --- internal/commands/issue/update/issue_update.go | 12 ++++++------ internal/commands/issue/update/issue_update_test.go | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/commands/issue/update/issue_update.go b/internal/commands/issue/update/issue_update.go index 9f5aeec06..5f9bdcc56 100644 --- a/internal/commands/issue/update/issue_update.go +++ b/internal/commands/issue/update/issue_update.go @@ -25,7 +25,7 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { Example: heredoc.Doc(` $ glab issue update 42 --label ui,ux $ glab issue update 42 --unlabel working - $ glab issue update 42 --linked-issues 10,15 --link-type blocks + $ glab issue update 42 --linked-issues 10,15 --link-type is_blocked_by $ glab issue update 42 --unlink-issues 10,15 `), Args: cobra.ExactArgs(1), @@ -231,7 +231,7 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { issueUpdateCmd.Flags().IntP("weight", "w", 0, "Set weight of the issue.") issueUpdateCmd.Flags().StringP("due-date", "", "", "A date in 'YYYY-MM-DD' format.") issueUpdateCmd.Flags().IntSlice("linked-issues", []int{}, "The IIDs of issues to link to this issue.") - issueUpdateCmd.Flags().String("link-type", "relates_to", "Type for the issue link (relates_to, blocks, blocked_by).") + issueUpdateCmd.Flags().String("link-type", "relates_to", "Type for the issue link (relates_to, blocks, is_blocked_by).") issueUpdateCmd.Flags().IntSlice("unlink-issues", []int{}, "The IIDs of issues to unlink from this issue.") return issueUpdateCmd @@ -255,12 +255,12 @@ func handleIssueLinks(cmd *cobra.Command, client *gitlab.Client, repo glrepo.Int // Validate link type validLinkTypes := map[string]bool{ - "relates_to": true, - "blocks": true, - "blocked_by": true, + "relates_to": true, + "blocks": true, + "is_blocked_by": true, } if !validLinkTypes[linkType] { - return nil, fmt.Errorf("invalid link type %q. Valid types are: relates_to, blocks, blocked_by", linkType) + return nil, fmt.Errorf("invalid link type %q. Valid types are: relates_to, blocks, is_blocked_by", linkType) } for _, targetIssueIID := range linkedIssues { diff --git a/internal/commands/issue/update/issue_update_test.go b/internal/commands/issue/update/issue_update_test.go index 9c7a947a5..142734942 100644 --- a/internal/commands/issue/update/issue_update_test.go +++ b/internal/commands/issue/update/issue_update_test.go @@ -54,7 +54,7 @@ func TestLinkTypeFlag_SetAndGet(t *testing.T) { cmd := NewCmdUpdate(f) // Test valid link types - validTypes := []string{"relates_to", "blocks", "blocked_by"} + validTypes := []string{"relates_to", "blocks", "is_blocked_by"} for _, linkType := range validTypes { err := cmd.Flags().Set("link-type", linkType) assert.NoError(t, err) @@ -82,8 +82,8 @@ func TestLinkTypeValidation(t *testing.T) { expectValid: true, }, { - name: "valid blocked_by", - linkType: "blocked_by", + name: "valid is_blocked_by", + linkType: "is_blocked_by", expectValid: true, }, { @@ -107,9 +107,9 @@ func TestLinkTypeValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Test the validation logic directly validLinkTypes := map[string]bool{ - "relates_to": true, - "blocks": true, - "blocked_by": true, + "relates_to": true, + "blocks": true, + "is_blocked_by": true, } isValid := validLinkTypes[tt.linkType] -- GitLab From 8bcab43e4c92ba46ca5187f0961a14e483238538 Mon Sep 17 00:00:00 2001 From: Joseph Burnett Date: Fri, 29 Aug 2025 13:59:22 -0700 Subject: [PATCH 8/8] Docs and epic tests --- docs/source/issue/update.md | 7 +- .../commands/issue/update/issue_update.go | 21 ++++++ .../issue/update/issue_update_test.go | 66 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/docs/source/issue/update.md b/docs/source/issue/update.md index 0c6ab9bf7..5082f3c8b 100644 --- a/docs/source/issue/update.md +++ b/docs/source/issue/update.md @@ -22,8 +22,10 @@ glab issue update [flags] ```console $ glab issue update 42 --label ui,ux $ glab issue update 42 --unlabel working -$ glab issue update 42 --linked-issues 10,15 --link-type blocks +$ glab issue update 42 --linked-issues 10,15 --link-type is_blocked_by $ glab issue update 42 --unlink-issues 10,15 +$ glab issue update 42 --epic 12345 +$ glab issue update 42 --epic 0 ``` @@ -34,8 +36,9 @@ $ glab issue update 42 --unlink-issues 10,15 -c, --confidential Make issue confidential -d, --description string Issue description. Set to "-" to open an editor. --due-date string A date in 'YYYY-MM-DD' format. + --epic int ID of the epic to assign this issue to. Set to 0 to remove from epic. -l, --label strings Add labels. - --link-type string Type for the issue link (relates_to, blocks, blocked_by). (default "relates_to") + --link-type string Type for the issue link (relates_to, blocks, is_blocked_by). (default "relates_to") --linked-issues ints The IIDs of issues to link to this issue. --lock-discussion Lock discussion on issue. -m, --milestone string Title of the milestone to assign Set to "" or 0 to unassign. diff --git a/internal/commands/issue/update/issue_update.go b/internal/commands/issue/update/issue_update.go index 5f9bdcc56..5b0f7627d 100644 --- a/internal/commands/issue/update/issue_update.go +++ b/internal/commands/issue/update/issue_update.go @@ -27,6 +27,8 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { $ glab issue update 42 --unlabel working $ glab issue update 42 --linked-issues 10,15 --link-type is_blocked_by $ glab issue update 42 --unlink-issues 10,15 + $ glab issue update 42 --epic 12345 + $ glab issue update 42 --epic 0 `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -184,6 +186,24 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { l.DueDate = gitlab.Ptr(dueDate) } + // Handle epic assignment + if cmd.Flags().Changed("epic") { + epicID, err := cmd.Flags().GetInt("epic") + if err != nil { + return err + } + + if epicID == 0 { + // Remove from epic + actions = append(actions, "removed from epic") + l.EpicID = gitlab.Ptr(0) + } else { + // Assign to epic + actions = append(actions, fmt.Sprintf("assigned to epic #%d", epicID)) + l.EpicID = gitlab.Ptr(epicID) + } + } + // Only call UpdateIssue API if there are actual issue property changes if len(actions) > 0 { fmt.Fprintf(out, "- Updating issue #%d\n", issue.IID) @@ -233,6 +253,7 @@ func NewCmdUpdate(f cmdutils.Factory) *cobra.Command { issueUpdateCmd.Flags().IntSlice("linked-issues", []int{}, "The IIDs of issues to link to this issue.") issueUpdateCmd.Flags().String("link-type", "relates_to", "Type for the issue link (relates_to, blocks, is_blocked_by).") issueUpdateCmd.Flags().IntSlice("unlink-issues", []int{}, "The IIDs of issues to unlink from this issue.") + issueUpdateCmd.Flags().Int("epic", 0, "ID of the epic to assign this issue to. Set to 0 to remove from epic.") return issueUpdateCmd } diff --git a/internal/commands/issue/update/issue_update_test.go b/internal/commands/issue/update/issue_update_test.go index 142734942..61583bdea 100644 --- a/internal/commands/issue/update/issue_update_test.go +++ b/internal/commands/issue/update/issue_update_test.go @@ -30,6 +30,9 @@ func TestNewCmdUpdate_Flags(t *testing.T) { unlinkIssuesFlag := cmd.Flags().Lookup("unlink-issues") assert.NotNil(t, unlinkIssuesFlag) + epicFlag := cmd.Flags().Lookup("epic") + assert.NotNil(t, epicFlag) + // Test default values linkType, err := cmd.Flags().GetString("link-type") assert.NoError(t, err) @@ -42,6 +45,10 @@ func TestNewCmdUpdate_Flags(t *testing.T) { unlinkIssues, err := cmd.Flags().GetIntSlice("unlink-issues") assert.NoError(t, err) assert.Empty(t, unlinkIssues) + + epicID, err := cmd.Flags().GetInt("epic") + assert.NoError(t, err) + assert.Equal(t, 0, epicID) } func TestLinkTypeFlag_SetAndGet(t *testing.T) { @@ -247,6 +254,65 @@ func TestStringConversionLogic(t *testing.T) { } } +func TestEpicFlag_SetAndGet(t *testing.T) { + cfg, err := config.Init() + assert.NoError(t, err) + + ios, _, _, _ := cmdtest.TestIOStreams() + f := cmdutils.NewFactory(ios, false, cfg, api.BuildInfo{}) + + cmd := NewCmdUpdate(f) + + // Test setting epic values + testValues := []int{0, 12345, 999999} + for _, epicID := range testValues { + err := cmd.Flags().Set("epic", fmt.Sprintf("%d", epicID)) + assert.NoError(t, err) + + value, err := cmd.Flags().GetInt("epic") + assert.NoError(t, err) + assert.Equal(t, epicID, value) + } +} + +func TestEpicAssignmentLogic(t *testing.T) { + tests := []struct { + name string + epicID int + expectedAction string + }{ + { + name: "assign to epic", + epicID: 12345, + expectedAction: "assigned to epic #12345", + }, + { + name: "remove from epic", + epicID: 0, + expectedAction: "removed from epic", + }, + { + name: "assign to different epic", + epicID: 99999, + expectedAction: "assigned to epic #99999", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the epic assignment action message logic + var action string + if tt.epicID == 0 { + action = "removed from epic" + } else { + action = fmt.Sprintf("assigned to epic #%d", tt.epicID) + } + + assert.Equal(t, tt.expectedAction, action, "Epic action message failed") + }) + } +} + // Helper type for testing relation mapping logic type mockRelation struct { IID int -- GitLab