@henryqw/pi-pr 3.1.8 → 3.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,7 +90,7 @@ It renames only the workspace. Outside Herdr, it does nothing.
90
90
 
91
91
  If Herdr lookup, JSON validation, or rename fails, the PR and normal UI refresh remain available. Each Herdr command has a 10-second timeout. The extension warns with `Herdr workspace rename failed: <error>`.
92
92
 
93
- Current-branch discovery matches the exact push repository and ref. It finds a fork-head PR whose base is an upstream repository. A unique historical match uses the exact remote push-ref OID, not local HEAD.
93
+ Current-branch discovery reads pull requests associated with the exact push repository ref. It does not run a global branch search. It finds a fork-head PR whose base is an upstream repository. A unique historical match uses the exact remote push-ref OID, not local HEAD.
94
94
 
95
95
  A no-action state includes a draft, merged or closed pull request, running CI, pending review, or blocked merge policy. It also includes a mutating workflow whose tree is dirty or whose local HEAD differs from the PR head.
96
96
 
@@ -18,11 +18,11 @@ import type {
18
18
  } from "./pr-routing.ts";
19
19
 
20
20
  const EXEC_TIMEOUT_MS = 10_000;
21
- const PR_SEARCH_PAGE_SIZE = 100;
22
- const PR_SEARCH_CAP = 1_000;
23
- const PR_SEARCH_MAX_PAGES = PR_SEARCH_CAP / PR_SEARCH_PAGE_SIZE;
21
+ const PR_DISCOVERY_PAGE_SIZE = 100;
22
+ const PR_DISCOVERY_CAP = 1_000;
23
+ const PR_DISCOVERY_MAX_PAGES = PR_DISCOVERY_CAP / PR_DISCOVERY_PAGE_SIZE;
24
24
  const PR_FIELDS = "id,number,url,state,isDraft,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup";
25
- const PR_SEARCH_QUERY = "query($searchQuery:String!,$endCursor:String){search(query:$searchQuery,type:ISSUE,first:100,after:$endCursor){issueCount edges{cursor node{__typename ...on PullRequest{number url state baseRepository{nameWithOwner}headRepository{nameWithOwner}headRefName headRefOid}}}pageInfo{hasNextPage startCursor endCursor}}}";
25
+ const PR_DISCOVERY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!,$endCursor:String){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name associatedPullRequests(first:100,after:$endCursor){totalCount edges{cursor node{__typename number url state baseRepository{nameWithOwner}headRepository{nameWithOwner}headRefName headRefOid}}pageInfo{hasNextPage startCursor endCursor}}}}}";
26
26
  const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
27
27
  const BASE_REF_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name target{oid}}}}";
28
28
  const BASE_BRANCH_POLICY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name branchProtectionRule{requiresStrictStatusChecks}}}}";
@@ -167,7 +167,7 @@ type SearchSelection =
167
167
  | { kind: "target-invalid" };
168
168
 
169
169
  type SearchPage = {
170
- issueCount: number;
170
+ totalCount: number;
171
171
  candidates: SearchPullRequest[];
172
172
  cursors: string[];
173
173
  hasNextPage: boolean;
@@ -634,18 +634,29 @@ function searchPullRequest(value: unknown, host: string): SearchPullRequest {
634
634
  };
635
635
  }
636
636
 
637
- function parseSearchPage(output: string, host: string): SearchPage {
637
+ function parseSearchPage(output: string, pushTarget: PushTarget): SearchPage | null {
638
638
  const page = parseJson(output, "Find pull requests");
639
639
  if (!isRecord(page)) fail("Find pull requests", "invalid GitHub CLI output");
640
640
  if (page.errors !== undefined) {
641
641
  if (!Array.isArray(page.errors)) fail("Find pull requests", "invalid GitHub CLI output");
642
642
  if (page.errors.length) fail("Find pull requests", "GitHub GraphQL returned errors");
643
643
  }
644
- const search = isRecord(page.data) ? page.data.search : undefined;
644
+ const repository = isRecord(page.data) ? page.data.repository : undefined;
645
+ if (!isRecord(repository)) fail("Find pull requests", "invalid GitHub CLI output");
645
646
  if (
646
- !isRecord(search) || typeof search.issueCount !== "number" ||
647
- !Number.isSafeInteger(search.issueCount) || search.issueCount < 0 ||
648
- !Array.isArray(search.edges) || search.edges.length > PR_SEARCH_PAGE_SIZE ||
647
+ normalizeRepository(repositoryName(repository.nameWithOwner, "Find pull requests", "repository.nameWithOwner")) !==
648
+ pushTarget.repository.normalizedName
649
+ ) fail("Find pull requests", "repository does not match push target");
650
+ if (repository.ref === null) return null;
651
+ if (!isRecord(repository.ref)) fail("Find pull requests", "invalid GitHub CLI output");
652
+ if (text(repository.ref.name, "Find pull requests", "ref.name") !== pushTarget.ref) {
653
+ fail("Find pull requests", "ref does not match push target");
654
+ }
655
+ const search = repository.ref.associatedPullRequests;
656
+ if (
657
+ !isRecord(search) || typeof search.totalCount !== "number" ||
658
+ !Number.isSafeInteger(search.totalCount) || search.totalCount < 0 ||
659
+ !Array.isArray(search.edges) || search.edges.length > PR_DISCOVERY_PAGE_SIZE ||
649
660
  !isRecord(search.pageInfo)
650
661
  ) fail("Find pull requests", "invalid GitHub CLI output");
651
662
  const candidates: SearchPullRequest[] = [];
@@ -653,7 +664,7 @@ function parseSearchPage(output: string, host: string): SearchPage {
653
664
  for (const edge of search.edges) {
654
665
  if (!isRecord(edge)) fail("Find pull requests", "invalid GitHub CLI output");
655
666
  cursors.push(text(edge.cursor, "Find pull requests", "cursor"));
656
- candidates.push(searchPullRequest(edge.node, host));
667
+ candidates.push(searchPullRequest(edge.node, pushTarget.repository.host));
657
668
  }
658
669
  if (new Set(cursors).size !== cursors.length) fail("Find pull requests", "duplicate candidate cursor");
659
670
  const { hasNextPage, startCursor, endCursor } = search.pageInfo;
@@ -664,7 +675,7 @@ function parseSearchPage(output: string, host: string): SearchPage {
664
675
  fail("Find pull requests", "invalid search pageInfo");
665
676
  }
666
677
  return {
667
- issueCount: search.issueCount,
678
+ totalCount: search.totalCount,
668
679
  candidates,
669
680
  cursors,
670
681
  hasNextPage,
@@ -1443,32 +1454,36 @@ async function searchPullRequests(
1443
1454
  context: PullRequestLoadContext,
1444
1455
  pushTarget: PushTarget,
1445
1456
  ): Promise<SearchSelection> {
1446
- const headOwner = pushTarget.repository.nameWithOwner.split("/")[0]!;
1447
- const searchQuery = `is:pr${pushTarget.provenance === "inferred" ? " is:open" : ""} head:${headOwner}:${pushTarget.ref}`;
1457
+ const [owner, name] = pushTarget.repository.nameWithOwner.split("/");
1448
1458
  const candidates: SearchPullRequest[] = [];
1449
1459
  const cursors = new Set<string>();
1450
- let issueCount: number | null = null;
1460
+ let totalCount: number | null = null;
1451
1461
  let endCursor: string | null = null;
1452
- for (let pageIndex = 0; pageIndex < PR_SEARCH_MAX_PAGES; pageIndex += 1) {
1462
+ for (let pageIndex = 0; pageIndex < PR_DISCOVERY_MAX_PAGES; pageIndex += 1) {
1453
1463
  const args = [
1454
1464
  "api",
1455
1465
  "graphql",
1456
1466
  "--hostname",
1457
1467
  pushTarget.repository.host,
1458
1468
  "-f",
1459
- `query=${PR_SEARCH_QUERY}`,
1469
+ `query=${PR_DISCOVERY_QUERY}`,
1470
+ "-F",
1471
+ `owner=${owner}`,
1472
+ "-F",
1473
+ `name=${name}`,
1460
1474
  "-F",
1461
- `searchQuery=${searchQuery}`,
1475
+ `qualifiedName=refs/heads/${pushTarget.ref}`,
1462
1476
  ];
1463
1477
  if (endCursor !== null) args.push("-F", `endCursor=${endCursor}`);
1464
1478
  const result = await execute(pi, context, "Find pull requests", "gh", args);
1465
- const page = parseSearchPage(result.stdout, pushTarget.repository.host);
1466
- if (issueCount !== null && page.issueCount !== issueCount) {
1479
+ const page = parseSearchPage(result.stdout, pushTarget);
1480
+ if (page === null) return pushTarget.remoteHeadOid === null ? { kind: "none" } : { kind: "target-invalid" };
1481
+ if (totalCount !== null && page.totalCount !== totalCount) {
1467
1482
  fail("Find pull requests", "inconsistent search result pages");
1468
1483
  }
1469
- issueCount = page.issueCount;
1470
- if (issueCount > PR_SEARCH_CAP) fail("Find pull requests", "GitHub search result cap reached");
1471
- const expectedPageSize = Math.min(PR_SEARCH_PAGE_SIZE, Math.max(0, issueCount - candidates.length));
1484
+ totalCount = page.totalCount;
1485
+ if (totalCount > PR_DISCOVERY_CAP) fail("Find pull requests", "GitHub pull request result cap reached");
1486
+ const expectedPageSize = Math.min(PR_DISCOVERY_PAGE_SIZE, Math.max(0, totalCount - candidates.length));
1472
1487
  if (page.candidates.length !== expectedPageSize) fail("Find pull requests", "incomplete search results");
1473
1488
  for (const cursor of page.cursors) {
1474
1489
  if (cursors.has(cursor)) fail("Find pull requests", "duplicate candidate cursor");
@@ -1478,7 +1493,7 @@ async function searchPullRequests(
1478
1493
  if (new Set(candidates.map(({ url }) => url.href.toLowerCase())).size !== candidates.length) {
1479
1494
  fail("Find pull requests", "duplicate candidate url");
1480
1495
  }
1481
- const hasMore = candidates.length < issueCount;
1496
+ const hasMore = candidates.length < totalCount;
1482
1497
  if (page.hasNextPage !== hasMore) fail("Find pull requests", "incomplete search results");
1483
1498
  if (!hasMore) {
1484
1499
  const selected = selectSearchPullRequest(candidates, pushTarget);
@@ -1498,7 +1513,7 @@ async function searchPullRequests(
1498
1513
  if (page.endCursor === null) fail("Find pull requests", "invalid search pageInfo");
1499
1514
  endCursor = page.endCursor;
1500
1515
  }
1501
- return fail("Find pull requests", "GitHub search result cap reached");
1516
+ return fail("Find pull requests", "GitHub pull request result cap reached");
1502
1517
  }
1503
1518
 
1504
1519
  export async function loadCurrentPullRequest(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "3.1.8",
3
+ "version": "3.1.9",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",