From 413df4af608ca486ada16b810b5f5fb23555f3cd Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 23 Jun 2026 08:50:21 -0500 Subject: [PATCH] feat(create): add --from-issue to create a task from a GitHub URL Add `ty create --from-issue ` accepting issue, PR, and discussion URLs. Pulls title/body/labels via the existing internal/github gh-backed layer, pre-fills the new task, carries labels as tags, links back to the source, and auto-detects the project by matching a project's git origin remote to the URL's owner/repo. For PR URLs, `--branch-from-pr` reuses the existing --branch/SourceBranch path to check out the PR's head branch in the worktree. Adds URL-parsing + remote-slug helpers in internal/github and tests for URL parsing, slug extraction, and task body/tag construction. Co-Authored-By: Claude Opus 4.8 --- cmd/task/from_issue_test.go | 62 ++++++++ cmd/task/main.go | 121 ++++++++++++++- internal/github/source.go | 273 +++++++++++++++++++++++++++++++++ internal/github/source_test.go | 98 ++++++++++++ 4 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 cmd/task/from_issue_test.go create mode 100644 internal/github/source.go create mode 100644 internal/github/source_test.go diff --git a/cmd/task/from_issue_test.go b/cmd/task/from_issue_test.go new file mode 100644 index 00000000..70d6c03d --- /dev/null +++ b/cmd/task/from_issue_test.go @@ -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) + } + }) + } +} diff --git a/cmd/task/main.go b/cmd/task/main.go index b5fb14a3..63d19377 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -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 @@ -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")) @@ -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 @@ -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) @@ -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 diff --git a/internal/github/source.go b/internal/github/source.go new file mode 100644 index 00000000..20fc65a7 --- /dev/null +++ b/internal/github/source.go @@ -0,0 +1,273 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// SourceKind identifies the type of GitHub object a URL points at. +type SourceKind string + +const ( + SourceIssue SourceKind = "issue" + SourcePR SourceKind = "pr" + SourceDiscussion SourceKind = "discussion" +) + +// SourceRef is a parsed reference to a GitHub issue, pull request, or discussion. +type SourceRef struct { + Owner string + Repo string + Kind SourceKind + Number int +} + +// NameWithOwner returns the "owner/repo" slug for the reference. +func (r SourceRef) NameWithOwner() string { + return r.Owner + "/" + r.Repo +} + +// SourceItem holds the details fetched for a GitHub issue/PR/discussion. +type SourceItem struct { + Ref SourceRef + Title string + Body string + URL string + Labels []string + State string + // HeadBranch is the source branch for a pull request (empty for issues/discussions). + HeadBranch string +} + +// sourceURLPattern matches GitHub issue/PR/discussion URLs and captures the +// owner, repo, object type, and number. It accepts optional trailing path or +// query fragments (e.g. "#issuecomment-123"). +var sourceURLPattern = regexp.MustCompile( + `^https?://github\.com/([^/\s]+)/([^/\s]+)/(issues|pull|discussions)/(\d+)`, +) + +// ParseSourceURL parses a GitHub issue, pull request, or discussion URL into a +// SourceRef. It returns an error if the URL is not a recognized GitHub URL. +func ParseSourceURL(rawURL string) (SourceRef, error) { + trimmed := strings.TrimSpace(rawURL) + m := sourceURLPattern.FindStringSubmatch(trimmed) + if m == nil { + return SourceRef{}, fmt.Errorf("not a recognized GitHub issue, pull request, or discussion URL: %q", rawURL) + } + + number, err := strconv.Atoi(m[4]) + if err != nil { + return SourceRef{}, fmt.Errorf("invalid number in URL %q: %w", rawURL, err) + } + + var kind SourceKind + switch m[3] { + case "issues": + kind = SourceIssue + case "pull": + kind = SourcePR + case "discussions": + kind = SourceDiscussion + } + + return SourceRef{ + Owner: m[1], + Repo: m[2], + Kind: kind, + Number: number, + }, nil +} + +// ghIssueResponse is the JSON response from `gh issue view`. +type ghIssueResponse struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` +} + +// ghPRSourceResponse is the JSON response from `gh pr view` for source import. +type ghPRSourceResponse struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state"` + HeadRefName string `json:"headRefName"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` +} + +// FetchSourceItem retrieves the details for a parsed GitHub reference using the +// gh CLI. Issues and pull requests are fetched via `gh issue/pr view`; +// discussions are best-effort via the GraphQL API. +func FetchSourceItem(ref SourceRef) (*SourceItem, error) { + if _, err := exec.LookPath("gh"); err != nil { + return nil, fmt.Errorf("gh CLI not found; install GitHub CLI (https://cli.github.com) to use --from-issue") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + switch ref.Kind { + case SourceIssue: + return fetchIssue(ctx, ref) + case SourcePR: + return fetchPR(ctx, ref) + case SourceDiscussion: + return fetchDiscussion(ctx, ref) + default: + return nil, fmt.Errorf("unsupported source kind: %q", ref.Kind) + } +} + +func fetchIssue(ctx context.Context, ref SourceRef) (*SourceItem, error) { + cmd := exec.CommandContext(ctx, "gh", "issue", "view", strconv.Itoa(ref.Number), + "--repo", ref.NameWithOwner(), + "--json", "title,body,url,state,labels") + output, err := cmd.Output() + if err != nil { + return nil, wrapGHError("fetch issue", err) + } + + var resp ghIssueResponse + if err := json.Unmarshal(output, &resp); err != nil { + return nil, fmt.Errorf("parse issue response: %w", err) + } + + item := &SourceItem{ + Ref: ref, + Title: resp.Title, + Body: resp.Body, + URL: resp.URL, + State: resp.State, + } + for _, l := range resp.Labels { + if l.Name != "" { + item.Labels = append(item.Labels, l.Name) + } + } + return item, nil +} + +func fetchPR(ctx context.Context, ref SourceRef) (*SourceItem, error) { + cmd := exec.CommandContext(ctx, "gh", "pr", "view", strconv.Itoa(ref.Number), + "--repo", ref.NameWithOwner(), + "--json", "title,body,url,state,headRefName,labels") + output, err := cmd.Output() + if err != nil { + return nil, wrapGHError("fetch pull request", err) + } + + var resp ghPRSourceResponse + if err := json.Unmarshal(output, &resp); err != nil { + return nil, fmt.Errorf("parse pull request response: %w", err) + } + + item := &SourceItem{ + Ref: ref, + Title: resp.Title, + Body: resp.Body, + URL: resp.URL, + State: resp.State, + HeadBranch: resp.HeadRefName, + } + for _, l := range resp.Labels { + if l.Name != "" { + item.Labels = append(item.Labels, l.Name) + } + } + return item, nil +} + +// ghDiscussionResponse mirrors the GraphQL shape returned for a discussion query. +type ghDiscussionResponse struct { + Data struct { + Repository struct { + Discussion struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + Labels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` + } `json:"discussion"` + } `json:"repository"` + } `json:"data"` +} + +// fetchDiscussion is best-effort: the gh CLI has no native discussion view, so +// we query the GraphQL API directly. +func fetchDiscussion(ctx context.Context, ref SourceRef) (*SourceItem, error) { + const query = `query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){discussion(number:$number){title body url labels(first:20){nodes{name}}}}}` + + cmd := exec.CommandContext(ctx, "gh", "api", "graphql", + "-f", "query="+query, + "-F", "owner="+ref.Owner, + "-F", "repo="+ref.Repo, + "-F", "number="+strconv.Itoa(ref.Number)) + output, err := cmd.Output() + if err != nil { + return nil, wrapGHError("fetch discussion", err) + } + + var resp ghDiscussionResponse + if err := json.Unmarshal(output, &resp); err != nil { + return nil, fmt.Errorf("parse discussion response: %w", err) + } + + d := resp.Data.Repository.Discussion + if d.Title == "" && d.Body == "" { + return nil, fmt.Errorf("discussion #%d not found in %s", ref.Number, ref.NameWithOwner()) + } + + item := &SourceItem{ + Ref: ref, + Title: d.Title, + Body: d.Body, + URL: d.URL, + } + for _, l := range d.Labels.Nodes { + if l.Name != "" { + item.Labels = append(item.Labels, l.Name) + } + } + return item, nil +} + +// remoteSlugPattern extracts "owner/repo" from common GitHub remote URL forms: +// https://github.com/owner/repo(.git), git@github.com:owner/repo(.git), and +// ssh://git@github.com/owner/repo(.git). +var remoteSlugPattern = regexp.MustCompile(`github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$`) + +// RepoSlugFromRemote returns the "owner/repo" slug for a git remote URL, or an +// empty string if the URL does not point at github.com. +func RepoSlugFromRemote(remoteURL string) string { + m := remoteSlugPattern.FindStringSubmatch(strings.TrimSpace(remoteURL)) + if m == nil { + return "" + } + return m[1] + "/" + m[2] +} + +// wrapGHError annotates a gh CLI error with its stderr output when available. +func wrapGHError(action string, err error) error { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + if stderr != "" { + return fmt.Errorf("%s: %s", action, stderr) + } + } + return fmt.Errorf("%s: %w", action, err) +} diff --git a/internal/github/source_test.go b/internal/github/source_test.go new file mode 100644 index 00000000..61a9a4b7 --- /dev/null +++ b/internal/github/source_test.go @@ -0,0 +1,98 @@ +package github + +import "testing" + +func TestParseSourceURL(t *testing.T) { + tests := []struct { + name string + url string + want SourceRef + wantErr bool + }{ + { + name: "issue", + url: "https://github.com/bborn/taskyou/issues/123", + want: SourceRef{Owner: "bborn", Repo: "taskyou", Kind: SourceIssue, Number: 123}, + }, + { + name: "pull request", + url: "https://github.com/bborn/taskyou/pull/45", + want: SourceRef{Owner: "bborn", Repo: "taskyou", Kind: SourcePR, Number: 45}, + }, + { + name: "discussion", + url: "https://github.com/bborn/taskyou/discussions/7", + want: SourceRef{Owner: "bborn", Repo: "taskyou", Kind: SourceDiscussion, Number: 7}, + }, + { + name: "issue with comment anchor", + url: "https://github.com/bborn/taskyou/issues/123#issuecomment-456", + want: SourceRef{Owner: "bborn", Repo: "taskyou", Kind: SourceIssue, Number: 123}, + }, + { + name: "http scheme and surrounding whitespace", + url: " http://github.com/octo-org/my.repo/pull/9 ", + want: SourceRef{Owner: "octo-org", Repo: "my.repo", Kind: SourcePR, Number: 9}, + }, + { + name: "not a github url", + url: "https://gitlab.com/foo/bar/issues/1", + wantErr: true, + }, + { + name: "missing number", + url: "https://github.com/foo/bar/issues", + wantErr: true, + }, + { + name: "commits path is not supported", + url: "https://github.com/foo/bar/commit/abc123", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSourceURL(tt.url) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseSourceURL(%q) expected error, got %+v", tt.url, got) + } + return + } + if err != nil { + t.Fatalf("ParseSourceURL(%q) unexpected error: %v", tt.url, err) + } + if got != tt.want { + t.Errorf("ParseSourceURL(%q) = %+v, want %+v", tt.url, got, tt.want) + } + }) + } +} + +func TestSourceRefNameWithOwner(t *testing.T) { + ref := SourceRef{Owner: "bborn", Repo: "taskyou"} + if got := ref.NameWithOwner(); got != "bborn/taskyou" { + t.Errorf("NameWithOwner() = %q, want %q", got, "bborn/taskyou") + } +} + +func TestRepoSlugFromRemote(t *testing.T) { + tests := []struct { + remote string + want string + }{ + {"https://github.com/bborn/taskyou.git", "bborn/taskyou"}, + {"https://github.com/bborn/taskyou", "bborn/taskyou"}, + {"git@github.com:bborn/taskyou.git", "bborn/taskyou"}, + {"ssh://git@github.com/bborn/taskyou.git", "bborn/taskyou"}, + {"https://github.com/bborn/taskyou/\n", "bborn/taskyou"}, + {"git@gitlab.com:bborn/taskyou.git", ""}, + {"", ""}, + } + for _, tt := range tests { + if got := RepoSlugFromRemote(tt.remote); got != tt.want { + t.Errorf("RepoSlugFromRemote(%q) = %q, want %q", tt.remote, got, tt.want) + } + } +}