diff --git a/README.md b/README.md index 8a63fb3..f303b54 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ linctl issue list --newer-than 1_day_ago # Get issue details (now includes git branch, cycle, project, attachments, and comments) linctl issue get LIN-123 +linctl issue get LIN-123 --download-attachments --output-dir ./downloads # Create a new issue linctl issue create --title "Bug fix" --team ENG @@ -167,6 +168,12 @@ linctl issue attach LIN-123 --pr https://github.com/owner/repo/pull/456 linctl issue attach LIN-123 --pr 456 # Resolves repo from git remote origin linctl issue attach LIN-123 --url https://example.com/spec --title "Spec" +# List/download attachments and uploads.linear.app links +linctl issue attachment list LIN-123 +linctl issue attachment download LIN-123 --all --output-dir ./downloads +linctl issue attachment download LIN-123 --id ATTACHMENT-ID +linctl issue attachment download LIN-123 --name spec.md --output ./spec.md + # Manage issue relations (blocks, blocked-by, related, duplicate, similar) linctl issue relation list LIN-123 linctl issue relation ls LIN-123 -j # JSON output @@ -351,6 +358,9 @@ linctl issue ls [flags] # Short alias # Get issue details (shows parent and sub-issues) linctl issue get linctl issue show # Alias +# Flags: + --download-attachments Download issue attachments and uploads.linear.app links from description/comments + --output-dir string Directory for downloaded attachments when using --download-attachments (default ".") # Create issue linctl issue create [flags] @@ -396,6 +406,19 @@ linctl issue attach [flags] --subtitle string Attachment subtitle --icon-url string Attachment icon URL +# List attachment entries (canonical attachments + uploads links from markdown) +linctl issue attachment list +linctl issue attachment ls # Alias + +# Download attachment entries +linctl issue attachment download [flags] +# Flags: + --all Download all attachment entries + --id string Download by canonical attachment ID + --name string Download by title or filename + --output string Write a single download to this path + --output-dir string Directory to save downloads (default ".") + ``` ### Issue Relation Commands diff --git a/SKILL.md b/SKILL.md index 83c6c82..8fe39e6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -97,6 +97,11 @@ linctl issue update LIN-123 --clear-labels linctl issue attach LIN-123 --pr https://github.com/org/repo/pull/123 linctl issue attach LIN-123 --url https://example.com/spec --title "Spec" +# List/download issue attachment files and uploads links +linctl issue attachment list LIN-123 --json +linctl issue attachment download LIN-123 --all --output-dir ./downloads +linctl issue get LIN-123 --download-attachments --output-dir ./downloads --json + # Add execution note linctl comment create LIN-123 --body "Implemented and verified locally." diff --git a/cmd/issue.go b/cmd/issue.go index 572f170..a7b5570 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -3,9 +3,15 @@ package cmd import ( "context" "fmt" + "io" + "mime" + "net/http" "net/url" "os" "os/exec" + "path" + "path/filepath" + "regexp" "strconv" "strings" @@ -36,6 +42,27 @@ Examples: linctl issue create --title "Bug fix" --team ENG`, } +var uploadsLinearURLPattern = regexp.MustCompile(`https://uploads\.linear\.app/[^\s<>"'\)\]]+`) + +type issueAttachmentEntry struct { + ID string `json:"id,omitempty"` + Title string `json:"title"` + URL string `json:"url"` + Source string `json:"source"` +} + +type issueAttachmentDownloadResult struct { + ID string `json:"id,omitempty"` + Title string `json:"title"` + URL string `json:"url"` + Source string `json:"source"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + FilePath string `json:"filePath,omitempty"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + var issueListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -310,7 +337,41 @@ var issueGetCmd = &cobra.Command{ os.Exit(1) } + downloadAttachments, _ := cmd.Flags().GetBool("download-attachments") + outputDir, _ := cmd.Flags().GetString("output-dir") + if strings.TrimSpace(outputDir) == "" { + outputDir = "." + } + + var downloadResults []issueAttachmentDownloadResult + hasDownloadFailure := false + if downloadAttachments { + entries := collectIssueAttachmentEntries(issue) + entriesToDownload, skippedResults, selectErr := selectAttachmentEntriesForDownload(entries, true, "", "") + if selectErr != nil { + output.Error(fmt.Sprintf("Failed to select attachments: %v", selectErr), plaintext, jsonOut) + os.Exit(1) + } + downloadResults, err = downloadIssueAttachmentEntries(context.Background(), authHeader, entriesToDownload, outputDir, "") + if err != nil { + output.Error(fmt.Sprintf("Failed to download attachments: %v", err), plaintext, jsonOut) + os.Exit(1) + } + downloadResults = append(skippedResults, downloadResults...) + hasDownloadFailure = hasAttachmentDownloadFailures(downloadResults) + } + if jsonOut { + if downloadAttachments { + output.JSON(map[string]interface{}{ + "issue": issue, + "downloads": downloadResults, + }) + if hasDownloadFailure { + os.Exit(1) + } + return + } output.JSON(issue) return } @@ -593,6 +654,13 @@ var issueGetCmd = &cobra.Command{ } } + if downloadAttachments { + renderAttachmentDownloadResults(downloadResults, plaintext, jsonOut) + if hasDownloadFailure { + os.Exit(1) + } + } + return } @@ -740,6 +808,13 @@ var issueGetCmd = &cobra.Command{ color.New(color.FgWhite, color.Faint).Sprint("→"), issue.Identifier) } + + if downloadAttachments { + renderAttachmentDownloadResults(downloadResults, plaintext, jsonOut) + if hasDownloadFailure { + os.Exit(1) + } + } }, } @@ -1625,6 +1700,506 @@ Examples: }, } +var issueAttachmentCmd = &cobra.Command{ + Use: "attachment", + Aliases: []string{"attachments"}, + Short: "List and download issue attachments", + Long: `List and download issue attachments and upload links. + +Examples: + linctl issue attachment list LIN-123 + linctl issue attachment download LIN-123 --all --output-dir ./downloads + linctl issue attachment download LIN-123 --id ATTACHMENT-ID + linctl issue attachment download LIN-123 --name spec.md --output ./spec.md`, +} + +var issueAttachmentListCmd = &cobra.Command{ + Use: "list [issue-id]", + Aliases: []string{"ls"}, + Short: "List issue attachments and upload links", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error("Not authenticated. Run 'linctl auth' first.", plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + issue, err := client.GetIssue(context.Background(), args[0]) + if err != nil { + output.Error(fmt.Sprintf("Failed to fetch issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + entries := collectIssueAttachmentEntries(issue) + if len(entries) == 0 { + output.Info(fmt.Sprintf("No attachment entries found for %s", issue.Identifier), plaintext, jsonOut) + return + } + + if jsonOut { + output.JSON(entries) + return + } + + if plaintext { + fmt.Printf("# Attachment Entries for %s\n\n", issue.Identifier) + for _, entry := range entries { + fmt.Printf("- **Title**: %s\n", entry.Title) + fmt.Printf(" - **Source**: %s\n", entry.Source) + if entry.ID != "" { + fmt.Printf(" - **ID**: %s\n", entry.ID) + } + fmt.Printf(" - **URL**: %s\n", entry.URL) + } + fmt.Printf("\nTotal: %d entries\n", len(entries)) + return + } + + headers := []string{"Title", "Source", "ID", "URL"} + rows := make([][]string, 0, len(entries)) + for _, entry := range entries { + rows = append(rows, []string{ + truncateString(entry.Title, 40), + entry.Source, + entry.ID, + entry.URL, + }) + } + output.Table(output.TableData{Headers: headers, Rows: rows}, false, false) + fmt.Printf("\n%s %d attachment entries\n", + color.New(color.FgGreen).Sprint("✓"), + len(entries)) + }, +} + +var issueAttachmentDownloadCmd = &cobra.Command{ + Use: "download [issue-id]", + Aliases: []string{"dl"}, + Short: "Download issue attachments and upload links", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error("Not authenticated. Run 'linctl auth' first.", plaintext, jsonOut) + os.Exit(1) + } + + downloadAll, _ := cmd.Flags().GetBool("all") + attachmentID, _ := cmd.Flags().GetString("id") + name, _ := cmd.Flags().GetString("name") + outputPath, _ := cmd.Flags().GetString("output") + outputDir, _ := cmd.Flags().GetString("output-dir") + + if strings.TrimSpace(outputDir) == "" { + outputDir = "." + } + + if downloadAll && (attachmentID != "" || name != "") { + output.Error("--all cannot be combined with --id or --name", plaintext, jsonOut) + os.Exit(1) + } + if attachmentID != "" && name != "" { + output.Error("--id and --name are mutually exclusive", plaintext, jsonOut) + os.Exit(1) + } + if outputPath != "" && outputDir != "." { + output.Error("--output and --output-dir are mutually exclusive", plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + issue, err := client.GetIssue(context.Background(), args[0]) + if err != nil { + output.Error(fmt.Sprintf("Failed to fetch issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + entries := collectIssueAttachmentEntries(issue) + selectedEntries, skippedResults, err := selectAttachmentEntriesForDownload(entries, downloadAll, attachmentID, name) + if err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } + + results, err := downloadIssueAttachmentEntries(context.Background(), authHeader, selectedEntries, outputDir, outputPath) + if err != nil { + output.Error(fmt.Sprintf("Failed to download attachments: %v", err), plaintext, jsonOut) + os.Exit(1) + } + results = append(skippedResults, results...) + + if jsonOut { + output.JSON(results) + } else { + renderAttachmentDownloadResults(results, plaintext, jsonOut) + } + + if hasAttachmentDownloadFailures(results) { + os.Exit(1) + } + }, +} + +func collectIssueAttachmentEntries(issue *api.Issue) []issueAttachmentEntry { + entries := make([]issueAttachmentEntry, 0) + seenURLs := make(map[string]bool) + + if issue != nil && issue.Attachments != nil { + for _, attachment := range issue.Attachments.Nodes { + normalizedURL := strings.TrimSpace(attachment.URL) + if normalizedURL == "" || seenURLs[normalizedURL] { + continue + } + seenURLs[normalizedURL] = true + + title := strings.TrimSpace(attachment.Title) + if title == "" { + title = defaultAttachmentTitleFromURL(normalizedURL) + } + + entries = append(entries, issueAttachmentEntry{ + ID: attachment.ID, + Title: title, + URL: normalizedURL, + Source: "attachment", + }) + } + } + + markdownTexts := make([]string, 0) + if issue != nil { + markdownTexts = append(markdownTexts, issue.Description) + if issue.Comments != nil { + for _, comment := range issue.Comments.Nodes { + markdownTexts = append(markdownTexts, comment.Body) + } + } + } + + for _, text := range markdownTexts { + for _, rawURL := range extractUploadsLinearURLs(text) { + normalizedURL := strings.TrimSpace(rawURL) + if normalizedURL == "" || seenURLs[normalizedURL] { + continue + } + seenURLs[normalizedURL] = true + entries = append(entries, issueAttachmentEntry{ + Title: defaultAttachmentTitleFromURL(normalizedURL), + URL: normalizedURL, + Source: "markdown", + }) + } + } + + return entries +} + +func extractUploadsLinearURLs(text string) []string { + if strings.TrimSpace(text) == "" { + return nil + } + matches := uploadsLinearURLPattern.FindAllString(text, -1) + urls := make([]string, 0, len(matches)) + for _, match := range matches { + cleaned := strings.TrimSpace(match) + cleaned = strings.TrimRight(cleaned, ".,;:!?)") + if cleaned != "" { + urls = append(urls, cleaned) + } + } + return urls +} + +func selectAttachmentEntriesForDownload(entries []issueAttachmentEntry, downloadAll bool, attachmentID, name string) ([]issueAttachmentEntry, []issueAttachmentDownloadResult, error) { + if len(entries) == 0 { + return nil, nil, fmt.Errorf("no attachment entries available") + } + + if downloadAll { + selected := make([]issueAttachmentEntry, 0, len(entries)) + skipped := make([]issueAttachmentDownloadResult, 0) + for _, entry := range entries { + downloadable, reason := isDownloadableAttachmentEntry(entry) + if downloadable { + selected = append(selected, entry) + continue + } + skipped = append(skipped, issueAttachmentDownloadResult{ + ID: entry.ID, + Title: entry.Title, + URL: entry.URL, + Source: entry.Source, + Status: "skipped", + Reason: reason, + Success: false, + }) + } + return selected, skipped, nil + } + + idValue := strings.TrimSpace(attachmentID) + if idValue != "" { + matches := make([]issueAttachmentEntry, 0, 1) + for _, entry := range entries { + if strings.EqualFold(entry.ID, idValue) { + matches = append(matches, entry) + break + } + } + if len(matches) == 0 { + return nil, nil, fmt.Errorf("attachment id %q not found", attachmentID) + } + return matches, nil, nil + } + + nameValue := strings.TrimSpace(name) + if nameValue != "" { + matches := make([]issueAttachmentEntry, 0) + for _, entry := range entries { + fileName := strings.TrimSpace(defaultAttachmentTitleFromURL(entry.URL)) + if strings.EqualFold(entry.Title, nameValue) || strings.EqualFold(fileName, nameValue) { + matches = append(matches, entry) + } + } + if len(matches) == 0 { + return nil, nil, fmt.Errorf("attachment name %q not found", name) + } + if len(matches) > 1 { + return nil, nil, fmt.Errorf("attachment name %q matched multiple entries; use --id or --all", name) + } + return matches, nil, nil + } + + if len(entries) == 1 { + return entries, nil, nil + } + return nil, nil, fmt.Errorf("multiple attachment entries found; specify --all, --id, or --name") +} + +func downloadIssueAttachmentEntries(ctx context.Context, authHeader string, entries []issueAttachmentEntry, outputDir, outputPath string) ([]issueAttachmentDownloadResult, error) { + if outputPath != "" && len(entries) != 1 { + return nil, fmt.Errorf("--output can only be used when downloading a single file") + } + + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, err + } + + results := make([]issueAttachmentDownloadResult, 0, len(entries)) + for _, entry := range entries { + result := issueAttachmentDownloadResult{ + ID: entry.ID, + Title: entry.Title, + URL: entry.URL, + Source: entry.Source, + } + + filePath, err := downloadAttachmentEntry(ctx, authHeader, entry, outputDir, outputPath) + if err != nil { + result.Status = "failed" + result.Success = false + result.Error = err.Error() + } else { + result.Status = "downloaded" + result.Success = true + result.FilePath = filePath + } + results = append(results, result) + } + + return results, nil +} + +func downloadAttachmentEntry(ctx context.Context, authHeader string, entry issueAttachmentEntry, outputDir, outputPath string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, entry.URL, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", authHeader) + req.Header.Set("User-Agent", "linctl/0.1.0") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("download failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + targetPath := strings.TrimSpace(outputPath) + if targetPath == "" { + filename := resolveDownloadFilename(resp, entry) + targetPath = uniqueDownloadPath(outputDir, filename) + } else { + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return "", err + } + } + + file, err := os.Create(targetPath) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + + if _, err := io.Copy(file, resp.Body); err != nil { + return "", err + } + + absPath, err := filepath.Abs(targetPath) + if err != nil { + return targetPath, nil + } + return absPath, nil +} + +func resolveDownloadFilename(resp *http.Response, entry issueAttachmentEntry) string { + if resp != nil { + if disposition := strings.TrimSpace(resp.Header.Get("Content-Disposition")); disposition != "" { + if _, params, err := mime.ParseMediaType(disposition); err == nil { + if encoded := strings.TrimSpace(params["filename*"]); encoded != "" { + if parts := strings.SplitN(encoded, "''", 2); len(parts) == 2 { + if unescaped, unescapeErr := url.QueryUnescape(parts[1]); unescapeErr == nil && strings.TrimSpace(unescaped) != "" { + return sanitizeFilename(unescaped) + } + } + } + if filename := strings.TrimSpace(params["filename"]); filename != "" { + return sanitizeFilename(filename) + } + } + } + } + + if title := sanitizeFilename(entry.Title); title != "" { + return title + } + if fromURL := sanitizeFilename(defaultAttachmentTitleFromURL(entry.URL)); fromURL != "" { + return fromURL + } + return "attachment" +} + +func sanitizeFilename(name string) string { + clean := strings.TrimSpace(name) + clean = strings.Trim(clean, "\"'") + clean = filepath.Base(clean) + if clean == "." || clean == "/" || clean == "" { + return "" + } + clean = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '-' + default: + return r + } + }, clean) + return strings.TrimSpace(clean) +} + +func uniqueDownloadPath(outputDir, filename string) string { + base := sanitizeFilename(filename) + if base == "" { + base = "attachment" + } + + candidate := filepath.Join(outputDir, base) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + + ext := filepath.Ext(base) + nameOnly := strings.TrimSuffix(base, ext) + for i := 2; i < 10000; i++ { + next := filepath.Join(outputDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext)) + if _, err := os.Stat(next); os.IsNotExist(err) { + return next + } + } + return filepath.Join(outputDir, fmt.Sprintf("%s-%d%s", nameOnly, os.Getpid(), ext)) +} + +func defaultAttachmentTitleFromURL(rawURL string) string { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "attachment" + } + base := path.Base(parsed.Path) + if base == "." || base == "/" || base == "" { + return "attachment" + } + return base +} + +func isDownloadableAttachmentEntry(entry issueAttachmentEntry) (bool, string) { + parsed, err := url.Parse(strings.TrimSpace(entry.URL)) + if err != nil || parsed.Host == "" { + return false, "invalid-url" + } + + host := strings.ToLower(parsed.Host) + pathLower := strings.ToLower(parsed.Path) + + if host == "uploads.linear.app" { + return true, "" + } + + if host == "github.com" && (strings.Contains(pathLower, "/pull/") || strings.Contains(pathLower, "/issues/")) { + return false, "non-downloadable-link" + } + + return true, "" +} + +func hasAttachmentDownloadFailures(results []issueAttachmentDownloadResult) bool { + for _, result := range results { + if result.Status == "failed" || (!result.Success && result.Status == "") { + return true + } + } + return false +} + +func renderAttachmentDownloadResults(results []issueAttachmentDownloadResult, plaintext, _ bool) { + if plaintext { + fmt.Printf("\n## Attachment Downloads\n") + for _, result := range results { + if result.Status == "skipped" { + fmt.Printf("- SKIPPED: %s (%s)\n", result.URL, result.Reason) + } else if result.Success { + fmt.Printf("- OK: %s -> %s\n", result.URL, result.FilePath) + } else { + fmt.Printf("- FAILED: %s (%s)\n", result.URL, result.Error) + } + } + return + } + + fmt.Printf("\n%s\n", color.New(color.FgYellow).Sprint("Attachment Downloads:")) + for _, result := range results { + if result.Status == "skipped" { + fmt.Printf(" %s %s (%s)\n", color.New(color.FgYellow).Sprint("→"), result.URL, result.Reason) + } else if result.Success { + fmt.Printf(" %s %s\n", color.New(color.FgGreen).Sprint("✓"), result.FilePath) + } else { + fmt.Printf(" %s %s (%s)\n", color.New(color.FgRed).Sprint("✗"), result.URL, result.Error) + } + } +} + func validateAttachmentURL(raw string) error { parsed, err := url.Parse(raw) if err != nil { @@ -1806,6 +2381,9 @@ func init() { issueCmd.AddCommand(issueCreateCmd) issueCmd.AddCommand(issueUpdateCmd) issueCmd.AddCommand(issueAttachCmd) + issueCmd.AddCommand(issueAttachmentCmd) + issueAttachmentCmd.AddCommand(issueAttachmentListCmd) + issueAttachmentCmd.AddCommand(issueAttachmentDownloadCmd) // Issue list flags issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee (email or 'me')") @@ -1830,6 +2408,10 @@ func init() { issueSearchCmd.Flags().StringP("sort", "o", "linear", "Sort order: linear (default), created, updated") issueSearchCmd.Flags().StringP("newer-than", "n", "", "Show issues created after this time (default: 6_months_ago, use 'all_time' for no filter)") + // Issue get flags + issueGetCmd.Flags().Bool("download-attachments", false, "Download issue attachments and uploads.linear.app links from description/comments") + issueGetCmd.Flags().String("output-dir", ".", "Directory to save downloaded attachments (used with --download-attachments)") + // Issue create flags issueCreateCmd.Flags().StringP("title", "", "", "Issue title (required)") issueCreateCmd.Flags().StringP("description", "d", "", "Issue description") @@ -1864,4 +2446,11 @@ func init() { issueAttachCmd.Flags().String("title", "", "Attachment title (required with --url)") issueAttachCmd.Flags().String("subtitle", "", "Attachment subtitle") issueAttachCmd.Flags().String("icon-url", "", "Attachment icon URL") + + // Issue attachment list/download flags + issueAttachmentDownloadCmd.Flags().Bool("all", false, "Download all attachment entries") + issueAttachmentDownloadCmd.Flags().String("id", "", "Download by canonical attachment ID") + issueAttachmentDownloadCmd.Flags().String("name", "", "Download by title or filename") + issueAttachmentDownloadCmd.Flags().String("output", "", "Write a single file to this path") + issueAttachmentDownloadCmd.Flags().String("output-dir", ".", "Directory to save downloaded files") } diff --git a/cmd/issue_cmd_test.go b/cmd/issue_cmd_test.go index 7034d08..66ab2af 100644 --- a/cmd/issue_cmd_test.go +++ b/cmd/issue_cmd_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "io" "net/http" + "reflect" "strings" "testing" + "github.com/dorkitude/linctl/pkg/api" "github.com/spf13/viper" ) @@ -83,3 +85,125 @@ func TestIssueCreateCmdStateResolvesToStateID(t *testing.T) { t.Fatalf("expected stateId state-2, got %q", sawStateID) } } + +func TestExtractUploadsLinearURLs(t *testing.T) { + text := ` +Main [doc](https://uploads.linear.app/abc-123/spec.md) and plain https://uploads.linear.app/def-456/log.txt. +Ignore https://example.com/file.txt +` + + got := extractUploadsLinearURLs(text) + want := []string{ + "https://uploads.linear.app/abc-123/spec.md", + "https://uploads.linear.app/def-456/log.txt", + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +} + +func TestCollectIssueAttachmentEntries(t *testing.T) { + issue := &api.Issue{ + Description: "See https://uploads.linear.app/path/from-description.md", + Attachments: &api.Attachments{ + Nodes: []api.Attachment{ + {ID: "att-1", Title: "Canonical", URL: "https://uploads.linear.app/path/from-attachment.md"}, + }, + }, + Comments: &api.Comments{ + Nodes: []api.Comment{ + {Body: "Another link https://uploads.linear.app/path/from-comment.md"}, + {Body: "Duplicate https://uploads.linear.app/path/from-attachment.md should not repeat"}, + }, + }, + } + + entries := collectIssueAttachmentEntries(issue) + if len(entries) != 3 { + t.Fatalf("expected 3 unique entries, got %d: %#v", len(entries), entries) + } + + if entries[0].Source != "attachment" || entries[0].ID != "att-1" { + t.Fatalf("expected first entry to be canonical attachment, got %#v", entries[0]) + } +} + +func TestSelectAttachmentEntriesForDownload(t *testing.T) { + entries := []issueAttachmentEntry{ + {ID: "a-1", Title: "spec.md", URL: "https://uploads.linear.app/x/spec.md", Source: "attachment"}, + {ID: "a-2", Title: "notes.md", URL: "https://uploads.linear.app/y/notes.md", Source: "attachment"}, + } + + gotAll, skippedAll, err := selectAttachmentEntriesForDownload(entries, true, "", "") + if err != nil || len(gotAll) != 2 || len(skippedAll) != 0 { + t.Fatalf("expected all entries selected without skips, got selected=%d skipped=%d err=%v", len(gotAll), len(skippedAll), err) + } + + gotID, skippedID, err := selectAttachmentEntriesForDownload(entries, false, "a-2", "") + if err != nil || len(gotID) != 1 || gotID[0].ID != "a-2" { + t.Fatalf("expected id selection a-2, got %#v err=%v", gotID, err) + } + if len(skippedID) != 0 { + t.Fatalf("expected no skipped entries for id selection, got %#v", skippedID) + } + + gotName, skippedName, err := selectAttachmentEntriesForDownload(entries, false, "", "spec.md") + if err != nil || len(gotName) != 1 || gotName[0].ID != "a-1" { + t.Fatalf("expected name selection spec.md, got %#v err=%v", gotName, err) + } + if len(skippedName) != 0 { + t.Fatalf("expected no skipped entries for name selection, got %#v", skippedName) + } +} + +func TestSelectAttachmentEntriesForDownloadAllSkipsNonDownloadableLinks(t *testing.T) { + entries := []issueAttachmentEntry{ + {ID: "a-1", Title: "pr", URL: "https://github.com/org/repo/pull/123", Source: "attachment"}, + {ID: "a-2", Title: "file.md", URL: "https://uploads.linear.app/abc/file.md", Source: "markdown"}, + } + + selected, skipped, err := selectAttachmentEntriesForDownload(entries, true, "", "") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(selected) != 1 || selected[0].ID != "a-2" { + t.Fatalf("expected only uploads entry selected, got %#v", selected) + } + if len(skipped) != 1 { + t.Fatalf("expected one skipped entry, got %#v", skipped) + } + if skipped[0].Status != "skipped" || skipped[0].Reason != "non-downloadable-link" { + t.Fatalf("unexpected skipped metadata: %#v", skipped[0]) + } +} + +func TestHasAttachmentDownloadFailuresIgnoresSkipped(t *testing.T) { + results := []issueAttachmentDownloadResult{ + {Status: "skipped", Success: false}, + {Status: "downloaded", Success: true}, + } + if hasAttachmentDownloadFailures(results) { + t.Fatalf("skipped entries should not count as failures") + } + + results = append(results, issueAttachmentDownloadResult{Status: "failed", Success: false}) + if !hasAttachmentDownloadFailures(results) { + t.Fatalf("failed entries must count as failures") + } +} + +func TestIssueAttachmentFlagsRegistered(t *testing.T) { + if issueGetCmd.Flags().Lookup("download-attachments") == nil { + t.Fatalf("issue get is missing --download-attachments") + } + if issueGetCmd.Flags().Lookup("output-dir") == nil { + t.Fatalf("issue get is missing --output-dir") + } + + for _, name := range []string{"all", "id", "name", "output", "output-dir"} { + if issueAttachmentDownloadCmd.Flags().Lookup(name) == nil { + t.Fatalf("issue attachment download is missing --%s", name) + } + } +} diff --git a/master_api_ref.md b/master_api_ref.md index e46e53e..1e918ac 100644 --- a/master_api_ref.md +++ b/master_api_ref.md @@ -307,6 +307,8 @@ linctl issue create|new linctl issue update linctl issue assign linctl issue attach +linctl issue attachment list|ls +linctl issue attachment download|dl ``` ### Projects