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:
xarmian
2026-03-28 11:20:00 -04:00
committed by GitHub
parent d2898edc61
commit 2f39d075d0
+464 -1
View File
@@ -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))
}
}