mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-19 09:05:58 +00:00
feat: GitHub PR integration for Pad items (#5)
* feat: add GitHub PR integration commands New `pad github` command group for linking GitHub PRs to Pad items: - `pad github link [item-ref]` — Link current branch's PR to a Pad item, with auto-detection of item refs from branch names (e.g. fix/TASK-5-desc) - `pad github status [item-ref]` — Show PR status for linked items in a table - `pad github unlink <item-ref>` — Remove a PR link from an item - `pad show` now displays linked PR info with colored state PR data stored in the existing fields JSON column — zero migrations needed. Shells out to `git` and `gh` CLI for git/GitHub interaction. * fix: github integration refinements - Fix headRepository field name for gh CLI - Extract repo from PR URL instead of headRepository response - Scan all collections for github status (workspace-level items API doesn't support the 'all' parameter as a flag) - Hide github_pr from regular fields display in show command
This commit is contained in:
+464
-1
@@ -27,6 +27,8 @@ import (
|
||||
"github.com/xarmian/pad/internal/cli"
|
||||
"github.com/xarmian/pad/internal/collections"
|
||||
"github.com/xarmian/pad/internal/config"
|
||||
"regexp"
|
||||
|
||||
"github.com/xarmian/pad/internal/events"
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
"github.com/xarmian/pad/internal/server"
|
||||
@@ -90,6 +92,7 @@ func main() {
|
||||
watchCmd(),
|
||||
webhooksCmd(),
|
||||
bulkUpdateCmd(),
|
||||
githubCmd(),
|
||||
)
|
||||
|
||||
rootCmd.RegisterFlagCompletionFunc("workspace", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
@@ -1398,11 +1401,14 @@ func showCmd() *cobra.Command {
|
||||
// Table format: show metadata + fields + content
|
||||
cli.PrintItemMeta(item)
|
||||
|
||||
// Print fields
|
||||
// Print fields (skip internal keys like github_pr which are shown separately)
|
||||
if item.Fields != "" && item.Fields != "{}" {
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(item.Fields), &fields); err == nil {
|
||||
for k, v := range fields {
|
||||
if k == "github_pr" {
|
||||
continue // shown in dedicated section below
|
||||
}
|
||||
fmt.Printf("%-12s %v\n", k+":", v)
|
||||
}
|
||||
fmt.Println("---")
|
||||
@@ -1413,6 +1419,35 @@ func showCmd() *cobra.Command {
|
||||
fmt.Println(item.Content)
|
||||
}
|
||||
|
||||
// Show GitHub PR if linked
|
||||
if item.Fields != "" {
|
||||
var fieldsMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(item.Fields), &fieldsMap); err == nil {
|
||||
if prRaw, ok := fieldsMap["github_pr"]; ok {
|
||||
if prMap, ok := prRaw.(map[string]interface{}); ok {
|
||||
fmt.Println("\n--- GitHub PR ---")
|
||||
prNum := ""
|
||||
if n, ok := prMap["number"].(float64); ok {
|
||||
prNum = fmt.Sprintf("#%d", int(n))
|
||||
}
|
||||
prState := fmt.Sprintf("%v", prMap["state"])
|
||||
prURL := fmt.Sprintf("%v", prMap["url"])
|
||||
prTitle := fmt.Sprintf("%v", prMap["title"])
|
||||
|
||||
stateColor := color.New(color.FgGreen)
|
||||
switch prState {
|
||||
case "MERGED":
|
||||
stateColor = color.New(color.FgMagenta)
|
||||
case "CLOSED":
|
||||
stateColor = color.New(color.FgRed)
|
||||
}
|
||||
fmt.Printf("PR %-6s %s %s\n", prNum, stateColor.Sprint(prState), color.New(color.Faint).Sprint(prURL))
|
||||
fmt.Printf(" %q\n", prTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show dependencies (blocks / blocked by)
|
||||
links, err := client.GetItemLinks(ws, item.Slug)
|
||||
if err == nil && len(links) > 0 {
|
||||
@@ -3760,3 +3795,431 @@ Examples:
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// GitHub integration
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GitHubPR holds PR data stored in item fields.
|
||||
type GitHubPR struct {
|
||||
Number int `json:"number"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
Branch string `json:"branch"`
|
||||
Repo string `json:"repo"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func githubCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "github",
|
||||
Short: "Link GitHub pull requests to Pad items",
|
||||
Aliases: []string{"gh"},
|
||||
Long: `Link GitHub pull requests to Pad items and view their status.
|
||||
|
||||
Requires the GitHub CLI (gh) to be installed: https://cli.github.com/
|
||||
|
||||
Examples:
|
||||
pad github link TASK-5 Link current branch's PR to TASK-5
|
||||
pad github link Auto-detect item ref from branch name
|
||||
pad github status Show PR status for all linked items
|
||||
pad github status TASK-5 Show PR status for a specific item
|
||||
pad github unlink TASK-5 Remove PR link from an item`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
githubLinkCmd(),
|
||||
githubStatusCmd(),
|
||||
githubUnlinkCmd(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getCurrentBranch returns the current git branch name.
|
||||
func getCurrentBranch() (string, error) {
|
||||
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("not in a git repository or git not available")
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// extractItemRefFromBranch attempts to find a Pad item reference (e.g. TASK-5, BUG-3) in a branch name.
|
||||
var itemRefPattern = regexp.MustCompile(`([A-Z]+-\d+)`)
|
||||
|
||||
func extractItemRefFromBranch(branch string) string {
|
||||
// Convert to uppercase for matching since branch names are often lowercase
|
||||
upper := strings.ToUpper(branch)
|
||||
match := itemRefPattern.FindString(upper)
|
||||
return match
|
||||
}
|
||||
|
||||
// fetchGitHubPR fetches PR data for the current branch using the gh CLI.
|
||||
func fetchGitHubPR() (*GitHubPR, error) {
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
return nil, fmt.Errorf("GitHub CLI (gh) not found. Install it from https://cli.github.com/")
|
||||
}
|
||||
|
||||
out, err := exec.Command("gh", "pr", "view", "--json", "number,url,title,state,headRefName,updatedAt").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no pull request found for the current branch. Create one with: gh pr create")
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Number int `json:"number"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
Branch string `json:"headRefName"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse gh output: %w", err)
|
||||
}
|
||||
|
||||
// Extract owner/repo from the PR URL (e.g. https://github.com/xarmian/pad/pull/5)
|
||||
repo := ""
|
||||
if parts := strings.Split(raw.URL, "/"); len(parts) >= 5 {
|
||||
repo = parts[3] + "/" + parts[4]
|
||||
}
|
||||
|
||||
return &GitHubPR{
|
||||
Number: raw.Number,
|
||||
URL: raw.URL,
|
||||
Title: raw.Title,
|
||||
State: raw.State,
|
||||
Branch: raw.Branch,
|
||||
Repo: repo,
|
||||
UpdatedAt: raw.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func githubLinkCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "link [item-ref]",
|
||||
Short: "Link the current branch's PR to a Pad item",
|
||||
Long: `Link the current branch's GitHub pull request to a Pad item.
|
||||
|
||||
If no item ref is provided, attempts to auto-detect from the branch name.
|
||||
For example, branch "fix/TASK-5-oauth-bug" would auto-link to TASK-5.
|
||||
|
||||
Examples:
|
||||
pad github link TASK-5
|
||||
pad github link fix-oauth-bug
|
||||
pad github link # auto-detect from branch name`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
dim := color.New(color.Faint)
|
||||
green := color.New(color.FgGreen, color.Bold)
|
||||
|
||||
// Step 1: Get current branch
|
||||
branch, err := getCurrentBranch()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dim.Printf("Branch: %s\n", branch)
|
||||
|
||||
// Step 2: Fetch PR info
|
||||
pr, err := fetchGitHubPR()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stateColor := prStateColor(pr.State)
|
||||
fmt.Printf("PR #%d %s %s\n", pr.Number, stateColor.Sprint(pr.State), dim.Sprint(pr.URL))
|
||||
fmt.Printf(" %s\n\n", bold.Sprint(pr.Title))
|
||||
|
||||
// Step 3: Determine target item
|
||||
var itemRef string
|
||||
if len(args) > 0 {
|
||||
itemRef = args[0]
|
||||
} else {
|
||||
itemRef = extractItemRefFromBranch(branch)
|
||||
if itemRef == "" {
|
||||
return fmt.Errorf("could not detect item ref from branch %q. Specify one: pad github link TASK-5", branch)
|
||||
}
|
||||
dim.Printf("Auto-detected item ref: %s\n", itemRef)
|
||||
}
|
||||
|
||||
// Step 4: Resolve the item
|
||||
item, err := client.GetItem(ws, itemRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("item %q not found: %w", itemRef, err)
|
||||
}
|
||||
|
||||
// Step 5: Update item fields with PR data
|
||||
var fieldsMap map[string]interface{}
|
||||
if item.Fields != "" && item.Fields != "{}" {
|
||||
if err := json.Unmarshal([]byte(item.Fields), &fieldsMap); err != nil {
|
||||
fieldsMap = make(map[string]interface{})
|
||||
}
|
||||
} else {
|
||||
fieldsMap = make(map[string]interface{})
|
||||
}
|
||||
|
||||
fieldsMap["github_pr"] = GitHubPR{
|
||||
Number: pr.Number,
|
||||
URL: pr.URL,
|
||||
Title: pr.Title,
|
||||
State: pr.State,
|
||||
Branch: pr.Branch,
|
||||
Repo: pr.Repo,
|
||||
UpdatedAt: pr.UpdatedAt,
|
||||
}
|
||||
|
||||
fieldsJSON, err := json.Marshal(fieldsMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal fields: %w", err)
|
||||
}
|
||||
fields := string(fieldsJSON)
|
||||
|
||||
_, err = client.UpdateItem(ws, item.Slug, models.ItemUpdate{
|
||||
Fields: &fields,
|
||||
LastModifiedBy: "user",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update item: %w", err)
|
||||
}
|
||||
|
||||
ref := cli.ItemRef(*item)
|
||||
green.Printf("✓ Linked PR #%d (%s) → %s %q\n", pr.Number, pr.Repo, ref, item.Title)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func githubStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status [item-ref]",
|
||||
Short: "Show GitHub PR status for linked items",
|
||||
Long: `Show the GitHub PR status for one or all items that have linked PRs.
|
||||
|
||||
Examples:
|
||||
pad github status # Show all items with linked PRs
|
||||
pad github status TASK-5 # Show PR status for a specific item`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
dim := color.New(color.Faint)
|
||||
|
||||
if len(args) > 0 {
|
||||
// Single item mode
|
||||
item, err := client.GetItem(ws, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return showItemPRStatus(item, bold, dim)
|
||||
}
|
||||
|
||||
// All items mode — scan across all collections for items with github_pr in fields
|
||||
colls, err := client.ListCollections(ws)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var items []models.Item
|
||||
for _, coll := range colls {
|
||||
collItems, err := client.ListCollectionItems(ws, coll.Slug, url.Values{
|
||||
"limit": {"100"},
|
||||
"include_archived": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, collItems...)
|
||||
}
|
||||
|
||||
if formatFlag == "json" {
|
||||
type prStatus struct {
|
||||
Ref string `json:"ref"`
|
||||
Title string `json:"title"`
|
||||
PR GitHubPR `json:"github_pr"`
|
||||
}
|
||||
var results []prStatus
|
||||
for _, item := range items {
|
||||
pr := extractPRFromFields(item.Fields)
|
||||
if pr != nil {
|
||||
results = append(results, prStatus{
|
||||
Ref: cli.ItemRef(item),
|
||||
Title: item.Title,
|
||||
PR: *pr,
|
||||
})
|
||||
}
|
||||
}
|
||||
return cli.PrintJSON(results)
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
dim.Sprint("REF"), dim.Sprint("TITLE"), dim.Sprint("PR"), dim.Sprint("STATE"), dim.Sprint("UPDATED"))
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
dim.Sprint("───"), dim.Sprint("─────"), dim.Sprint("──"), dim.Sprint("─────"), dim.Sprint("───────"))
|
||||
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
pr := extractPRFromFields(item.Fields)
|
||||
if pr == nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
|
||||
ref := cli.ItemRef(item)
|
||||
title := item.Title
|
||||
if len(title) > 40 {
|
||||
title = title[:37] + "..."
|
||||
}
|
||||
stateColor := prStateColor(pr.State)
|
||||
updatedAgo := ""
|
||||
if pr.UpdatedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, pr.UpdatedAt); err == nil {
|
||||
updatedAgo = relativeTimeStr(t)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(tw, "%s\t%s\t#%d\t%s\t%s\n",
|
||||
bold.Sprint(ref), title, pr.Number, stateColor.Sprint(pr.State), dim.Sprint(updatedAgo))
|
||||
}
|
||||
tw.Flush()
|
||||
|
||||
if count == 0 {
|
||||
fmt.Println(dim.Sprint("\nNo items have linked PRs. Use: pad github link TASK-5"))
|
||||
} else {
|
||||
fmt.Printf("\n%d item(s) with linked PRs\n", count)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func githubUnlinkCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "unlink <item-ref>",
|
||||
Short: "Remove the GitHub PR link from an item",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
item, err := client.GetItem(ws, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fieldsMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(item.Fields), &fieldsMap); err != nil {
|
||||
return fmt.Errorf("failed to parse item fields: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := fieldsMap["github_pr"]; !ok {
|
||||
return fmt.Errorf("item %q has no linked PR", args[0])
|
||||
}
|
||||
|
||||
delete(fieldsMap, "github_pr")
|
||||
fieldsJSON, _ := json.Marshal(fieldsMap)
|
||||
fields := string(fieldsJSON)
|
||||
|
||||
_, err = client.UpdateItem(ws, item.Slug, models.ItemUpdate{
|
||||
Fields: &fields,
|
||||
LastModifiedBy: "user",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen, color.Bold)
|
||||
green.Printf("✓ Removed PR link from %s %q\n", cli.ItemRef(*item), item.Title)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for GitHub integration
|
||||
|
||||
func showItemPRStatus(item *models.Item, bold, dim *color.Color) error {
|
||||
pr := extractPRFromFields(item.Fields)
|
||||
if pr == nil {
|
||||
return fmt.Errorf("item %q has no linked PR", item.Slug)
|
||||
}
|
||||
|
||||
ref := cli.ItemRef(*item)
|
||||
stateColor := prStateColor(pr.State)
|
||||
|
||||
bold.Printf("%s %s\n", ref, item.Title)
|
||||
fmt.Printf("PR #%d %s %s\n", pr.Number, stateColor.Sprint(pr.State), dim.Sprint(pr.URL))
|
||||
if pr.Branch != "" {
|
||||
fmt.Printf("Branch: %s\n", dim.Sprint(pr.Branch))
|
||||
}
|
||||
if pr.Repo != "" {
|
||||
fmt.Printf("Repo: %s\n", dim.Sprint(pr.Repo))
|
||||
}
|
||||
if pr.UpdatedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, pr.UpdatedAt); err == nil {
|
||||
fmt.Printf("Updated: %s\n", dim.Sprint(relativeTimeStr(t)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractPRFromFields(fieldsJSON string) *GitHubPR {
|
||||
if fieldsJSON == "" || fieldsJSON == "{}" {
|
||||
return nil
|
||||
}
|
||||
var fieldsMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil {
|
||||
return nil
|
||||
}
|
||||
prRaw, ok := fieldsMap["github_pr"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Re-marshal and unmarshal to properly extract the struct
|
||||
prJSON, err := json.Marshal(prRaw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var pr GitHubPR
|
||||
if err := json.Unmarshal(prJSON, &pr); err != nil {
|
||||
return nil
|
||||
}
|
||||
if pr.Number == 0 {
|
||||
return nil
|
||||
}
|
||||
return &pr
|
||||
}
|
||||
|
||||
func prStateColor(state string) *color.Color {
|
||||
switch state {
|
||||
case "OPEN":
|
||||
return color.New(color.FgGreen, color.Bold)
|
||||
case "MERGED":
|
||||
return color.New(color.FgMagenta, color.Bold)
|
||||
case "CLOSED":
|
||||
return color.New(color.FgRed)
|
||||
default:
|
||||
return color.New(color.Faint)
|
||||
}
|
||||
}
|
||||
|
||||
func relativeTimeStr(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user