From 808b35bfc5a81927597f2593e335335df0056f7e Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 10 Jun 2026 16:08:57 -0700 Subject: [PATCH] feat(gh-nudge): skip draft PRs when nudging Draft PRs are not yet ready for review, so nudging their reviewers is noise. Fetch the isDraft field from the gh CLI and filter drafts out of GetPendingPullRequests so neither the nudge nor the auto-merge path acts on them. --- devtools/gh-nudge/internal/github/github.go | 16 +++++- .../gh-nudge/internal/github/github_test.go | 56 +++++++++++++++++++ devtools/gh-nudge/internal/models/pr.go | 1 + 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/devtools/gh-nudge/internal/github/github.go b/devtools/gh-nudge/internal/github/github.go index 9c52c73..7bbcd42 100644 --- a/devtools/gh-nudge/internal/github/github.go +++ b/devtools/gh-nudge/internal/github/github.go @@ -62,7 +62,7 @@ func NewClientWithExecutor(executor CommandExecutor) *Client { const pullRequestListLimit = 100 // GetPendingPullRequests fetches pending pull requests using the gh CLI. -// It fetches open PRs created by the current user. +// It fetches open PRs created by the current user, excluding drafts. func (c *Client) GetPendingPullRequests() ([]models.PullRequest, error) { // Construct the gh command to get PR information. // This fetches open PRs authored by the current user with their title, URL, @@ -71,7 +71,7 @@ func (c *Client) GetPendingPullRequests() ([]models.PullRequest, error) { output, err := c.executor.Execute("gh", "pr", "list", "--author", "@me", "--limit", fmt.Sprintf("%d", pullRequestListLimit), - "--json", "url,title,reviewRequests,files,mergeable,headRefName,statusCheckRollup") + "--json", "url,title,reviewRequests,files,mergeable,headRefName,statusCheckRollup,isDraft") if err != nil { return nil, fmt.Errorf("failed to execute gh command: %w", err) } @@ -82,7 +82,17 @@ func (c *Client) GetPendingPullRequests() ([]models.PullRequest, error) { return nil, fmt.Errorf("failed to parse gh command output: %w", err) } - return prs, nil + // Skip draft PRs: reviewers are not expected to act on them yet, so + // nudging about them would be noise. + pending := make([]models.PullRequest, 0, len(prs)) + for _, pr := range prs { + if pr.IsDraft { + continue + } + pending = append(pending, pr) + } + + return pending, nil } // GetMergeablePullRequests fetches pull requests with no review requests. diff --git a/devtools/gh-nudge/internal/github/github_test.go b/devtools/gh-nudge/internal/github/github_test.go index efed64d..f26b554 100644 --- a/devtools/gh-nudge/internal/github/github_test.go +++ b/devtools/gh-nudge/internal/github/github_test.go @@ -276,6 +276,62 @@ func TestGetPendingPullRequests(t *testing.T) { } }) + t.Run("skips draft PRs", func(t *testing.T) { + sampleJSON := `[ + { + "title": "Draft PR", + "url": "https://github.com/org/repo/pull/1", + "files": [], + "reviewRequests": [{"__typename": "User", "login": "reviewer"}], + "isDraft": true + }, + { + "title": "Ready PR", + "url": "https://github.com/org/repo/pull/2", + "files": [], + "reviewRequests": [{"__typename": "User", "login": "reviewer"}], + "isDraft": false + } + ]` + + client := NewClientWithExecutor(&mockExecutor{output: sampleJSON}) + prs, err := client.GetPendingPullRequests() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(prs) != 1 { + t.Fatalf("expected 1 non-draft PR, got %d", len(prs)) + } + if prs[0].Title != "Ready PR" { + t.Errorf("expected non-draft PR to be returned, got %q", prs[0].Title) + } + }) + + t.Run("treats PRs without isDraft field as non-draft", func(t *testing.T) { + sampleJSON := `[ + { + "title": "PR without isDraft", + "url": "https://github.com/org/repo/pull/1", + "files": [], + "reviewRequests": [] + } + ]` + + client := NewClientWithExecutor(&mockExecutor{output: sampleJSON}) + prs, err := client.GetPendingPullRequests() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(prs) != 1 { + t.Fatalf("expected 1 PR, got %d", len(prs)) + } + if prs[0].IsDraft { + t.Error("expected PR without isDraft field to default to non-draft") + } + }) + t.Run("parses PR with multiple files", func(t *testing.T) { sampleJSON := `[ { diff --git a/devtools/gh-nudge/internal/models/pr.go b/devtools/gh-nudge/internal/models/pr.go index 4a23c90..30b9241 100644 --- a/devtools/gh-nudge/internal/models/pr.go +++ b/devtools/gh-nudge/internal/models/pr.go @@ -18,6 +18,7 @@ type PullRequest struct { Mergeable string `json:"mergeable,omitempty"` HeadRefName string `json:"headRefName,omitempty"` StatusCheckRollup []StatusCheck `json:"statusCheckRollup,omitempty"` + IsDraft bool `json:"isDraft,omitempty"` } // StatusCheck represents a single CI check or status from GitHub's statusCheckRollup.