Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions cmd/task/from_issue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"strings"
"testing"

"github.com/bborn/workflow/internal/github"
)

func TestMergeSourceBody(t *testing.T) {
item := &github.SourceItem{
Body: "Steps to reproduce:\n1. do a thing\n",
URL: "https://github.com/bborn/taskyou/issues/123",
}

t.Run("uses imported body when user body empty", func(t *testing.T) {
got := mergeSourceBody("", item)
want := "Steps to reproduce:\n1. do a thing\n\nSource: https://github.com/bborn/taskyou/issues/123"
if got != want {
t.Errorf("mergeSourceBody() = %q, want %q", got, want)
}
})

t.Run("keeps user body and appends link", func(t *testing.T) {
got := mergeSourceBody("my own notes", item)
want := "my own notes\n\nSource: https://github.com/bborn/taskyou/issues/123"
if got != want {
t.Errorf("mergeSourceBody() = %q, want %q", got, want)
}
})

t.Run("always links back to source", func(t *testing.T) {
empty := &github.SourceItem{URL: "https://github.com/bborn/taskyou/pull/9"}
got := mergeSourceBody("", empty)
if !strings.Contains(got, "Source: https://github.com/bborn/taskyou/pull/9") {
t.Errorf("mergeSourceBody() = %q, missing source link", got)
}
})
}

func TestMergeSourceTags(t *testing.T) {
tests := []struct {
name string
existing string
labels []string
want string
}{
{"labels only", "", []string{"bug", "ui"}, "bug,ui"},
{"existing only", "urgent", nil, "urgent"},
{"merge", "urgent", []string{"bug", "ui"}, "urgent,bug,ui"},
{"dedupes case-insensitively", "Bug", []string{"bug", "ui"}, "Bug,ui"},
{"trims whitespace", " a , b ", []string{" c "}, "a,b,c"},
{"drops empties", ",,", []string{"", "x"}, "x"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mergeSourceTags(tt.existing, tt.labels); got != tt.want {
t.Errorf("mergeSourceTags(%q, %v) = %q, want %q", tt.existing, tt.labels, got, tt.want)
}
})
}
}
121 changes: 120 additions & 1 deletion cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,9 @@ Examples:
task create "Refactor auth" --executor codex # Use Codex instead of Claude
task create "Urgent bug" --tags "bug,urgent" --pinned # Tagged and pinned task
task create --body "The login button is broken on mobile devices" # AI generates title
task create "QA: PR #2526" --branch fix/ui-overflow --project myapp # Checkout existing branch`,
task create "QA: PR #2526" --branch fix/ui-overflow --project myapp # Checkout existing branch
task create --from-issue https://github.com/owner/repo/issues/123 # Create from a GitHub issue
task create --from-issue https://github.com/owner/repo/pull/45 --branch-from-pr # Import a PR and check out its branch`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var title string
Expand All @@ -598,8 +600,44 @@ Examples:
pinned, _ := cmd.Flags().GetBool("pinned")
remoteControl, _ := cmd.Flags().GetBool("remote-control")
branch, _ := cmd.Flags().GetString("branch")
fromIssue, _ := cmd.Flags().GetString("from-issue")
branchFromPR, _ := cmd.Flags().GetBool("branch-from-pr")
outputJSON, _ := cmd.Flags().GetBool("json")

// --from-issue: pull title/body/labels from a GitHub issue, PR, or
// discussion and pre-fill the task. Detected values only fill fields
// the user did not explicitly set.
var sourceRef *github.SourceRef
if fromIssue != "" {
ref, err := github.ParseSourceURL(fromIssue)
if err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
}
item, err := github.FetchSourceItem(ref)
if err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error fetching "+string(ref.Kind)+": "+err.Error()))
os.Exit(1)
}
sourceRef = &ref

if strings.TrimSpace(title) == "" {
title = item.Title
}
body = mergeSourceBody(body, item)
tags = mergeSourceTags(tags, item.Labels)

// For PRs, optionally check out the PR's branch (unless the user
// already gave an explicit --branch).
if ref.Kind == github.SourcePR && branchFromPR && branch == "" {
branch = item.HeadBranch
}

if !outputJSON {
fmt.Println(dimStyle.Render(fmt.Sprintf("Imported %s %s/%s#%d", string(ref.Kind), ref.Owner, ref.Repo, ref.Number)))
}
}

// Validate that either title or body is provided
if strings.TrimSpace(title) == "" && strings.TrimSpace(body) == "" {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: either title or --body must be provided"))
Expand Down Expand Up @@ -674,6 +712,17 @@ Examples:
}
}

// If still unset and we imported from a GitHub URL, try to match a
// project whose git remote points at the same owner/repo.
if project == "" && sourceRef != nil {
if name := detectProjectByRepo(database, sourceRef.NameWithOwner()); name != "" {
project = name
if !outputJSON {
fmt.Println(dimStyle.Render("Detected project from repo: " + project))
}
}
}

// Generate title from body if title is empty
if strings.TrimSpace(title) == "" && strings.TrimSpace(body) != "" {
var apiKey string
Expand Down Expand Up @@ -778,6 +827,8 @@ Examples:
createCmd.Flags().Bool("pinned", false, "Pin the task to the top of its column")
createCmd.Flags().Bool("remote-control", false, "Launch Claude with --remote-control (interactive, remote-drivable session)")
createCmd.Flags().StringP("branch", "b", "", "Existing branch to checkout for worktree (e.g., fix/ui-overflow)")
createCmd.Flags().String("from-issue", "", "Create the task from a GitHub issue, PR, or discussion URL (pre-fills title/body, carries labels as tags, links back, auto-detects project)")
createCmd.Flags().Bool("branch-from-pr", false, "When --from-issue points at a PR, check out the PR's branch in the worktree")
createCmd.Flags().Bool("json", false, "Output in JSON format")
createCmd.RegisterFlagCompletionFunc("project", completeFlagProjects)
createCmd.RegisterFlagCompletionFunc("type", completeFlagTypes)
Expand Down Expand Up @@ -4809,6 +4860,74 @@ func unescapeNewlines(s string) string {
return strings.ReplaceAll(s, "\\n", "\n")
}

// mergeSourceBody combines a user-provided body with the imported issue/PR body
// and a link back to the source. If the user supplied their own body it is kept
// at the top, followed by the source link; otherwise the imported body is used.
func mergeSourceBody(userBody string, item *github.SourceItem) string {
link := fmt.Sprintf("Source: %s", item.URL)

var sections []string
if strings.TrimSpace(userBody) != "" {
sections = append(sections, strings.TrimRight(userBody, "\n"))
} else if strings.TrimSpace(item.Body) != "" {
sections = append(sections, strings.TrimRight(item.Body, "\n"))
}
sections = append(sections, link)
return strings.Join(sections, "\n\n")
}

// mergeSourceTags merges existing comma-separated tags with imported labels,
// preserving order and dropping duplicates (case-insensitive).
func mergeSourceTags(existing string, labels []string) string {
var merged []string
seen := make(map[string]bool)
add := func(tag string) {
tag = strings.TrimSpace(tag)
if tag == "" {
return
}
key := strings.ToLower(tag)
if seen[key] {
return
}
seen[key] = true
merged = append(merged, tag)
}

for _, t := range strings.Split(existing, ",") {
add(t)
}
for _, l := range labels {
add(l)
}
return strings.Join(merged, ",")
}

// detectProjectByRepo returns the name of the project whose git origin remote
// points at the given "owner/repo" slug, or an empty string if none match.
func detectProjectByRepo(database *db.DB, nameWithOwner string) string {
if nameWithOwner == "" {
return ""
}
projects, err := database.ListProjects()
if err != nil {
return ""
}
for _, p := range projects {
if p.Path == "" {
continue
}
out, err := osexec.Command("git", "-C", p.Path, "remote", "get-url", "origin").Output()
if err != nil {
continue
}
if strings.EqualFold(github.RepoSlugFromRemote(string(out)), nameWithOwner) {
return p.Name
}
}
return ""
}

// truncate shortens a string to maxLen, adding ellipsis if needed.
func truncate(s string, maxLen int) string {
// Replace newlines with spaces for single-line display
Expand Down
Loading