Skip to content

PR search by multiple exact branch names #10816

Open

Description

@GMNGeoffrey

Describe the feature or problem you’d like to solve

I think the current search functionality of gh pr list has surprising behavior in that the --head foo argument or head:foo search atom will return any PR whose head branch starts with "foo". This appears to match the behavior of the web UI (https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests#search-by-branch-name). While this arguably makes sense for an interactive web interface, I think this is surprising behavior for a command line interface and scripting tool. This is especially bad because there doesn't seem to be any way to search for an exact match

Proposed solution

Options:

  1. Make the search atom and flag for the head branch perform an exact match. To perform a prefix match, the syntax could be extended with standard * wildcards or full regex.
  2. Add a method to specify an exact search. Maybe quoting the branch name?

Of these, I prefer number 1. I think this is what the behavior should have been in the first place and is the expected behavior for this sort of tool. But I understand this would be a breaking change, so some alternative syntax would be another option. I think that's going to be a lot more awkward though.

Additional context

My use case is searching for merged PRs that match my local branches so that I can clean them up. Obviously an exact match is important here.

Activity

  1. andyfeller commented on Apr 17, 2025

    @andyfeller
    Contributor

    Thanks for reading out about this concern, @GMNGeoffrey! 🙇

    Questions

    1. Can you show me a public repository where you've encountered this problem with gh pr list --head ... including the PRs and related branches?

    Unable to reproduce reported behavior

    Sadly, I was not able to reproduce the problem you describe as I crafted 2 separate pull requests in the same repository with the same branch naming base (cli-10816) with one having a suffix.

    As you can see in the shell snippet below, gh pr list --head only receives exact matches back. 🤔

    Repo: https://github.com/tinyfists/gh-pr-test

    Branches:

    PRs:

    $ git branch | cat
      cli-10816
    * cli-10816-2
      main
    
    $ gh version
    gh version 2.70.0 (2025-04-11)
    https://github.com/cli/cli/releases/tag/v2.70.0
    
    $ gh pr list --head cli-10816
    Showing 1 of 1 pull request in tinyfists/gh-pr-test that matches your search
    
    ID  TITLE                              BRANCH     CREATED AT         
    #8  Adding file from cli-10816 branch  cli-10816  about 2 minutes ago
    
    $ gh pr list --head cli-10816-2
    Showing 1 of 1 pull request in tinyfists/gh-pr-test that matches your search
    
    ID  TITLE                                 BRANCH       CREATED AT            
    #9  Adding files from cli-10816-2 branch  cli-10816-2  less than a minute ago

    Additionally, here are examples from the cli/cli repository as we have several PRs where branches have similar bases that this should show up but doesn't:

    cli/cli $ gh pr list --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36"
    no pull requests match your search in cli/cli
    
    cli/cli $ gh pr list --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36.3"
    Showing 1 of 1 pull request in cli/cli that matches your search
    
    ID      TITLE                                                  BRANCH                                                   CREATED AT        
    #10250  Bump google.golang.org/protobuf from 1.36.2 to 1.36.3  dependabot/go_modules/google.golang.org/protobuf-1.36.3  about 3 months ago
    
    cli/cli $ gh pr list --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36.4"
    Showing 1 of 1 pull request in cli/cli that matches your search
    
    ID      TITLE                                                  BRANCH                                                   CREATED AT        
    #10306  Bump google.golang.org/protobuf from 1.36.3 to 1.36.4  dependabot/go_modules/google.golang.org/protobuf-1.36.4  about 2 months ago
    
    cli/cli $ gh pr list --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36.5"
    Showing 1 of 1 pull request in cli/cli that matches your search
    
    ID      TITLE                                                  BRANCH                                                   CREATED AT        
    #10379  Bump google.golang.org/protobuf from 1.36.4 to 1.36.5  dependabot/go_modules/google.golang.org/protobuf-1.36.5  about 2 months ago
    
    cli/cli $ gh pr list --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36.6"
    Showing 1 of 1 pull request in cli/cli that matches your search
    
    ID      TITLE                                                  BRANCH                                                   CREATED AT       
    #10659  Bump google.golang.org/protobuf from 1.36.5 to 1.36.6  dependabot/go_modules/google.golang.org/protobuf-1.36.6  about 24 days ago
  2. GMNGeoffrey commented on Apr 17, 2025

    @GMNGeoffrey
    Author

    Thanks for the quick response. I think you're right that this actually doesn't apply to the --head flag: only the head: search atom. I thought I checked that, but apparently not correctly...

    $ gh pr list -R cli/cli --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36"
    no pull requests match your search in cli/cli
    $ gh pr list -R cli/cli --state all --search "head:dependabot/go_modules/google.golang.org/protobuf-1.36"
    
    Showing 4 of 4 pull requests in cli/cli that match your search
    
    ID      TITLE                                                  BRANCH                                                   CREATED AT
    #10659  Bump google.golang.org/protobuf from 1.36.5 to 1.36.6  dependabot/go_modules/google.golang.org/protobuf-1.36.6  about 24 days ago
    #10379  Bump google.golang.org/protobuf from 1.36.4 to 1.36.5  dependabot/go_modules/google.golang.org/protobuf-1.36.5  about 2 months ago
    #10306  Bump google.golang.org/protobuf from 1.36.3 to 1.36.4  dependabot/go_modules/google.golang.org/protobuf-1.36.4  about 2 months ago
    #10250  Bump google.golang.org/protobuf from 1.36.2 to 1.36.3  dependabot/go_modules/google.golang.org/protobuf-1.36.3  about 3 months ago

    I think a fuzzy match is actually more justifiable in the context of a search string. The issue is that the generic search query is the only way to search for multiple head branches AFAICT, so there's no way to get PRs for branch 'foo' and branch 'bar', but not 'foobar' other than filtering after the fact.

  3. andyfeller commented on Apr 21, 2025

    @andyfeller
    Contributor

    I think you're right that this actually doesn't apply to the --head flag: only the head: search atom. I thought I checked that, but apparently not correctly...

    $ gh pr list -R cli/cli --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36"
    no pull requests match your search in cli/cli
    $ gh pr list -R cli/cli --state all --search "head:dependabot/go_modules/google.golang.org/protobuf-1.36"

    Showing 4 of 4 pull requests in cli/cli that match your search

    ID TITLE BRANCH CREATED AT
    #10659 Bump google.golang.org/protobuf from 1.36.5 to 1.36.6 dependabot/go_modules/google.golang.org/protobuf-1.36.6 about 24 days ago
    #10379 Bump google.golang.org/protobuf from 1.36.4 to 1.36.5 dependabot/go_modules/google.golang.org/protobuf-1.36.5 about 2 months ago
    #10306 Bump google.golang.org/protobuf from 1.36.3 to 1.36.4 dependabot/go_modules/google.golang.org/protobuf-1.36.4 about 2 months ago
    #10250 Bump google.golang.org/protobuf from 1.36.2 to 1.36.3 dependabot/go_modules/google.golang.org/protobuf-1.36.3 about 3 months ago
    I think a fuzzy match is actually more justifiable in the context of a search string. The issue is that the generic search query is the only way to search for multiple head branches AFAICT, so there's no way to get PRs for branch 'foo' and branch 'bar', but not 'foobar' other than filtering after the fact.

    Thanks for the follow up and additional context, @GMNGeoffrey! 🙇

    I think you're correct:

    • the --base / --head flags are used to exact match by Repository.pullRequests

    • the --search flag relies upon search query where base / head search qualifiers are fuzzy matched server-side

    • you could filter this client-side using the --json and --jq flags

      gh pr list -R cli/cli --state all --search "head:dependabot/go_modules/google.golang.org/protobuf-1.36" --jq 'map(select(.headRefName | match("1.36.(3|6)")))' --json url,number,title,headRefName

      resulting in:

      [
        {
          "headRefName": "dependabot/go_modules/google.golang.org/protobuf-1.36.6",
          "number": 10659,
          "title": "Bump google.golang.org/protobuf from 1.36.5 to 1.36.6",
          "url": "https://github.com/cli/cli/pull/10659"
        },
        {
          "headRefName": "dependabot/go_modules/google.golang.org/protobuf-1.36.3",
          "number": 10250,
          "title": "Bump google.golang.org/protobuf from 1.36.2 to 1.36.3",
          "url": "https://github.com/cli/cli/pull/10250"
        }
      ]

    Questions

    1. How does this context reshape this suggestion? Are you suggesting:

      • GitHub Search API provide exact head and base matching?
      • GitHub CLI parse search queries and perform exact matching client-side under certain conditions?

    Debug output from above commands

    gh pr list --state all --head "dependabot/go_modules/google.golang.org/protobuf-1.36.3"

    [git remote -v]
    [git config --get-regexp ^remote\..*\.gh-resolved$]
    * Request at 2025-04-21 08:40:57.005118 -0400 EDT m=+0.087366168
    * Request to https://api.github.com/graphql
    > POST /graphql HTTP/1.1
    > Host: api.github.com
    > Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
    > Authorization: token ████████████████████
    > Content-Length: 381
    > Content-Type: application/json; charset=utf-8
    > Graphql-Features: merge_queue
    > Time-Zone: America/New_York
    > User-Agent: GitHub CLI 2.70.0
    
    GraphQL query:
    fragment repo on Repository {
        id
        name
        owner { login }
        viewerPermission
        defaultBranchRef {
          name
        }
        isPrivate
      }
      query RepositoryNetwork {
        viewer { login }
        
        repo_000: repository(owner: "cli", name: "cli") {
          ...repo
          parent {
            ...repo
          }
        }
        
      }
    GraphQL variables: null
    
    < HTTP/2.0 200 OK
    < Access-Control-Allow-Origin: *
    < Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
    < Content-Security-Policy: default-src 'none'
    < Content-Type: application/json; charset=utf-8
    < Date: Mon, 21 Apr 2025 12:40:57 GMT
    < Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
    < Server: github.com
    < Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
    < Vary: Accept-Encoding, Accept, X-Requested-With
    < X-Accepted-Oauth-Scopes: repo
    < X-Content-Type-Options: nosniff
    < X-Frame-Options: deny
    < X-Github-Media-Type: github.v4; param=merge-info-preview.nebula-preview; format=json
    < X-Github-Request-Id: E16D:393C87:8D18CE:11A962E:68063CD9
    < X-Oauth-Client-Id: 178c6fc778ccc68e1d6a
    < X-Oauth-Scopes: admin:org, gist, read:project, repo, workflow
    < X-Ratelimit-Limit: 5000
    < X-Ratelimit-Remaining: 5000
    < X-Ratelimit-Reset: 1745242857
    < X-Ratelimit-Resource: graphql
    < X-Ratelimit-Used: 1
    < X-Xss-Protection: 0
    
    {
      "data": {
        "viewer": {
          "login": "andyfeller"
        },
        "repo_000": {
          "id": "MDEwOlJlcG9zaXRvcnkyMTI2MTMwNDk=",
          "name": "cli",
          "owner": {
            "login": "cli"
          },
          "viewerPermission": "ADMIN",
          "defaultBranchRef": {
            "name": "trunk"
          },
          "isPrivate": false,
          "parent": null
        }
      }
    }
    
    * Request took 390.948083ms
    * Request at 2025-04-21 08:40:57.451665 -0400 EDT m=+0.533911918
    * Request to https://api.github.com/graphql
    > POST /graphql HTTP/1.1
    > Host: api.github.com
    > Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
    > Authorization: token ████████████████████
    > Content-Length: 1026
    > Content-Type: application/json; charset=utf-8
    > Graphql-Features: merge_queue
    > Time-Zone: America/New_York
    > User-Agent: GitHub CLI 2.70.0
    
    GraphQL query:
    fragment pr on PullRequest{number,title,state,url,headRefName,headRepositoryOwner{id,login,...on User{name}},isCrossRepository,isDraft,createdAt}
        query PullRequestList(
          $owner: String!,
          $repo: String!,
          $limit: Int!,
          $endCursor: String,
          $baseBranch: String,
          $headBranch: String,
          $state: [PullRequestState!] = OPEN
        ) {
          repository(owner: $owner, name: $repo) {
            pullRequests(
              states: $state,
              baseRefName: $baseBranch,
              headRefName: $headBranch,
              first: $limit,
              after: $endCursor,
              orderBy: {field: CREATED_AT, direction: DESC}
            ) {
              totalCount
              nodes {
                ...pr
              }
              pageInfo {
                hasNextPage
                endCursor
              }
            }
          }
        }
    GraphQL variables: {"headBranch":"dependabot/go_modules/google.golang.org/protobuf-1.36.3","limit":30,"owner":"cli","repo":"cli","state":["OPEN","CLOSED","MERGED"]}
    
    
    < HTTP/2.0 200 OK
    < Access-Control-Allow-Origin: *
    < Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
    < Content-Security-Policy: default-src 'none'
    < Content-Type: application/json; charset=utf-8
    < Date: Mon, 21 Apr 2025 12:40:57 GMT
    < Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
    < Server: github.com
    < Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
    < Vary: Accept-Encoding, Accept, X-Requested-With
    < X-Accepted-Oauth-Scopes: repo
    < X-Content-Type-Options: nosniff
    < X-Frame-Options: deny
    < X-Github-Media-Type: github.v4; param=merge-info-preview.nebula-preview; format=json
    < X-Github-Request-Id: E16D:393C87:8D1A23:11A9896:68063CD9
    < X-Oauth-Client-Id: 178c6fc778ccc68e1d6a
    < X-Oauth-Scopes: admin:org, gist, read:project, repo, workflow
    < X-Ratelimit-Limit: 5000
    < X-Ratelimit-Remaining: 5000
    < X-Ratelimit-Reset: 1745242857
    < X-Ratelimit-Resource: graphql
    < X-Ratelimit-Used: 2
    < X-Xss-Protection: 0
    
    {
      "data": {
        "repository": {
          "pullRequests": {
            "totalCount": 1,
            "nodes": [
              {
                "number": 10250,
                "title": "Bump google.golang.org/protobuf from 1.36.2 to 1.36.3",
                "state": "MERGED",
                "url": "https://github.com/cli/cli/pull/10250",
                "headRefName": "dependabot/go_modules/google.golang.org/protobuf-1.36.3",
                "headRepositoryOwner": {
                  "id": "MDEyOk9yZ2FuaXphdGlvbjU5NzA0NzEx",
                  "login": "cli"
                },
                "isCrossRepository": false,
                "isDraft": false,
                "createdAt": "2025-01-15T14:49:33Z"
              }
            ],
            "pageInfo": {
              "hasNextPage": false,
              "endCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wMS0xNVQwOTo0OTozMy0wNTowMM6H3LIA"
            }
          }
        }
      }
    }
    
    * Request took 376.591667ms
    
    Showing 1 of 1 pull request in cli/cli that matches your search
    
    ID      TITLE                                                  BRANCH                                                   CREATED AT        
    #10250  Bump google.golang.org/protobuf from 1.36.2 to 1.36.3  dependabot/go_modules/google.golang.org/protobuf-1.36.3  about 3 months ago

    gh pr list -R cli/cli --state all --search "head:dependabot/go_modules/google.golang.org/protobuf-1.36"

    * Request at 2025-04-21 08:49:22.428251 -0400 EDT m=+0.073699292
    * Request to https://api.github.com/graphql
    > POST /graphql HTTP/1.1
    > Host: api.github.com
    > Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
    > Authorization: token ████████████████████
    > Content-Length: 605
    > Content-Type: application/json; charset=utf-8
    > Graphql-Features: merge_queue
    > Time-Zone: America/New_York
    > User-Agent: GitHub CLI 2.70.0
    
    GraphQL query:
    fragment pr on PullRequest{number,title,state,url,headRefName,headRepositoryOwner{id,login,...on User{name}},isCrossRepository,isDraft,createdAt}
        query PullRequestSearch(
          $q: String!,
          $limit: Int!,
          $endCursor: String,
        ) {
          search(query: $q, type: ISSUE, first: $limit, after: $endCursor) {
            issueCount
            nodes {
              ...pr
            }
            pageInfo {
              hasNextPage
              endCursor
            }
          }
        }
    GraphQL variables: {"limit":30,"q":"head:dependabot/go_modules/google.golang.org/protobuf-1.36 repo:cli/cli type:pr"}
    
    < HTTP/2.0 200 OK
    < Access-Control-Allow-Origin: *
    < Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
    < Content-Security-Policy: default-src 'none'
    < Content-Type: application/json; charset=utf-8
    < Date: Mon, 21 Apr 2025 12:49:22 GMT
    < Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
    < Server: github.com
    < Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
    < Vary: Accept-Encoding, Accept, X-Requested-With
    < X-Accepted-Oauth-Scopes: repo
    < X-Content-Type-Options: nosniff
    < X-Frame-Options: deny
    < X-Github-Media-Type: github.v4; param=merge-info-preview.nebula-preview; format=json
    < X-Github-Request-Id: E5BD:93D47:11EAD9DF:23B1B631:68063ED2
    < X-Oauth-Client-Id: 178c6fc778ccc68e1d6a
    < X-Oauth-Scopes: admin:org, gist, read:project, repo, workflow
    < X-Ratelimit-Limit: 5000
    < X-Ratelimit-Remaining: 5000
    < X-Ratelimit-Reset: 1745242857
    < X-Ratelimit-Resource: graphql
    < X-Ratelimit-Used: 3
    < X-Xss-Protection: 0
    
    {
      "data": {
        "search": {
          "issueCount": 4,
          "nodes": [
            {
              "number": 10659,
              "title": "Bump google.golang.org/protobuf from 1.36.5 to 1.36.6",
              "state": "CLOSED",
              "url": "https://github.com/cli/cli/pull/10659",
              "headRefName": "dependabot/go_modules/google.golang.org/protobuf-1.36.6",
              "headRepositoryOwner": {
                "id": "MDEyOk9yZ2FuaXphdGlvbjU5NzA0NzEx",
                "login": "cli"
              },
              "isCrossRepository": false,
              "isDraft": false,
              "createdAt": "2025-03-24T16:04:54Z"
            },
            {
              "number": 10379,
              "title": "Bump google.golang.org/protobuf from 1.36.4 to 1.36.5",
              "state": "MERGED",
              "url": "https://github.com/cli/cli/pull/10379",
              "headRefName": "dependabot/go_modules/google.golang.org/protobuf-1.36.5",
              "headRepositoryOwner": {
                "id": "MDEyOk9yZ2FuaXphdGlvbjU5NzA0NzEx",
                "login": "cli"
              },
              "isCrossRepository": false,
              "isDraft": false,
              "createdAt": "2025-02-06T14:17:32Z"
            },
            {
              "number": 10306,
              "title": "Bump google.golang.org/protobuf from 1.36.3 to 1.36.4",
              "state": "MERGED",
              "url": "https://github.com/cli/cli/pull/10306",
              "headRefName": "dependabot/go_modules/google.golang.org/protobuf-1.36.4",
              "headRepositoryOwner": {
                "id": "MDEyOk9yZ2FuaXphdGlvbjU5NzA0NzEx",
                "login": "cli"
              },
              "isCrossRepository": false,
              "isDraft": false,
              "createdAt": "2025-01-24T14:49:08Z"
            },
            {
              "number": 10250,
              "title": "Bump google.golang.org/protobuf from 1.36.2 to 1.36.3",
              "state": "MERGED",
              "url": "https://github.com/cli/cli/pull/10250",
              "headRefName": "dependabot/go_modules/google.golang.org/protobuf-1.36.3",
              "headRepositoryOwner": {
                "id": "MDEyOk9yZ2FuaXphdGlvbjU5NzA0NzEx",
                "login": "cli"
              },
              "isCrossRepository": false,
              "isDraft": false,
              "createdAt": "2025-01-15T14:49:33Z"
            }
          ],
          "pageInfo": {
            "hasNextPage": false,
            "endCursor": "Y3Vyc29yOjQ="
          }
        }
      }
    }
    
    * Request took 443.679417ms
    
    Showing 4 of 4 pull requests in cli/cli that match your search
    
    ID      TITLE                                                  BRANCH                                                   CREATED AT        
    #10659  Bump google.golang.org/protobuf from 1.36.5 to 1.36.6  dependabot/go_modules/google.golang.org/protobuf-1.36.6  about 27 days ago
    #10379  Bump google.golang.org/protobuf from 1.36.4 to 1.36.5  dependabot/go_modules/google.golang.org/protobuf-1.36.5  about 2 months ago
    #10306  Bump google.golang.org/protobuf from 1.36.3 to 1.36.4  dependabot/go_modules/google.golang.org/protobuf-1.36.4  about 2 months ago
    #10250  Bump google.golang.org/protobuf from 1.36.2 to 1.36.3  dependabot/go_modules/google.golang.org/protobuf-1.36.3  about 3 months ago

  4. changed the title [-]PR search by exact branch name[/-] [+]PR search by multiple exact branch names[/+] on Apr 21, 2025
  5. GMNGeoffrey commented on Apr 21, 2025

    @GMNGeoffrey
    Author

    Yeah I'm doing client-side filtering with jq now. It's a bit more involved to construct than your example because I'm trying to find a whole list of exact matches, but it works (https://gist.github.com/GMNGeoffrey/38d27939cb751ab066df59927f807f37 if anyone's curious). I still found the implicit fuzzy matching of gh (because of the GitHub API) surprising, but I think it's a reasonable design decision for a "search" endpoint. I guess the thing I would want is either the ability to OR filters like --head in the gh pr list command (without the search flag, which seems to activate a totally different path) or the ability to specify an exact match in the GitHub PR search API. I think this isn't obviously a flaw in the current implementation though, and it seems likely only worth it if you get other people who want the same thing

  6. self-assigned this
    on Aug 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementa request to improve CLIgh-prrelating to the gh pr command

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions