@hcrosse/opencode-pr-tracker 0.1.0 → 0.3.0

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/dist/server.js CHANGED
@@ -1555,34 +1555,40 @@ var require_proper_lockfile = __commonJS((exports, module) => {
1555
1555
  // src/server.ts
1556
1556
  import { tool } from "@opencode-ai/plugin";
1557
1557
 
1558
- // src/state.ts
1559
- var import_proper_lockfile = __toESM(require_proper_lockfile(), 1);
1560
- import { createHash, randomUUID } from "crypto";
1561
- import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1562
- import { homedir } from "os";
1563
- import { join } from "path";
1558
+ // src/github.ts
1559
+ import { execFile } from "child_process";
1560
+
1561
+ // src/exhaustive.ts
1562
+ function casesHandled(value) {
1563
+ throw new Error(`Unhandled case: ${String(value)}`);
1564
+ }
1564
1565
 
1565
1566
  // src/url.ts
1567
+ var expectedPullRequestUrl = "Expected https://github.com/<owner>/<repository>/pull/<positive-integer> or github.com/<owner>/<repository>/pull/<positive-integer>";
1566
1568
  var invalidPullRequestUrl = {
1567
1569
  ok: false,
1568
1570
  error: {
1569
1571
  tag: "InvalidPullRequestUrl",
1570
- message: "Expected https://github.com/<owner>/<repository>/pull/<positive-integer>"
1572
+ message: expectedPullRequestUrl
1571
1573
  }
1572
1574
  };
1573
1575
  var segmentPattern = /^[A-Za-z0-9._-]+$/;
1576
+ var schemeLessPrefix = "github.com/";
1574
1577
  function parsePullRequestUrl(input) {
1575
- if (input.trim() !== input)
1578
+ if (/\s/.test(input))
1576
1579
  return invalidPullRequestUrl;
1577
- if (!input.startsWith("https://"))
1580
+ if (input.includes("\\"))
1578
1581
  return invalidPullRequestUrl;
1579
- const authorityEnd = input.indexOf("/", "https://".length);
1582
+ const candidate = input.slice(0, schemeLessPrefix.length).toLowerCase() === schemeLessPrefix ? `https://${input}` : input;
1583
+ if (!candidate.startsWith("https://"))
1584
+ return invalidPullRequestUrl;
1585
+ const authorityEnd = candidate.indexOf("/", "https://".length);
1580
1586
  if (authorityEnd === -1)
1581
1587
  return invalidPullRequestUrl;
1582
- if (input.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1588
+ if (candidate.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1583
1589
  return invalidPullRequestUrl;
1584
1590
  }
1585
- const rawPath = input.slice(authorityEnd).split(/[?#]/, 1).join("");
1591
+ const rawPath = candidate.slice(authorityEnd).split(/[?#]/, 1).join("");
1586
1592
  for (const segment of rawPath.split("/")) {
1587
1593
  let decoded;
1588
1594
  try {
@@ -1595,7 +1601,7 @@ function parsePullRequestUrl(input) {
1595
1601
  }
1596
1602
  let parsed;
1597
1603
  try {
1598
- parsed = new URL(input);
1604
+ parsed = new URL(candidate);
1599
1605
  } catch {
1600
1606
  return invalidPullRequestUrl;
1601
1607
  }
@@ -1606,15 +1612,17 @@ function parsePullRequestUrl(input) {
1606
1612
  if (segments.length !== 5 || segments[0] !== "" || segments[3] !== "pull") {
1607
1613
  return invalidPullRequestUrl;
1608
1614
  }
1609
- const owner = segments[1];
1610
- const repository = segments[2];
1615
+ const rawOwner = segments[1];
1616
+ const rawRepository = segments[2];
1611
1617
  const numberText = segments[4];
1612
- if (owner === undefined || repository === undefined || numberText === undefined || !segmentPattern.test(owner) || !segmentPattern.test(repository) || !/^\d+$/.test(numberText)) {
1618
+ if (rawOwner === undefined || rawRepository === undefined || numberText === undefined || !segmentPattern.test(rawOwner) || !segmentPattern.test(rawRepository) || !/^\d+$/.test(numberText)) {
1613
1619
  return invalidPullRequestUrl;
1614
1620
  }
1615
1621
  const number = Number(numberText);
1616
1622
  if (!Number.isSafeInteger(number) || number <= 0)
1617
1623
  return invalidPullRequestUrl;
1624
+ const owner = rawOwner.toLowerCase();
1625
+ const repository = rawRepository.toLowerCase();
1618
1626
  const url = `https://github.com/${owner}/${repository}/pull/${number}`;
1619
1627
  const value = { url, owner, repository, number };
1620
1628
  return { ok: true, value };
@@ -1623,7 +1631,914 @@ function formatPullRequestRef(pullRequest) {
1623
1631
  return `${pullRequest.owner}/${pullRequest.repository}#${pullRequest.number}`;
1624
1632
  }
1625
1633
 
1634
+ // src/github.ts
1635
+ var invalidGitHubResponse = {
1636
+ ok: false,
1637
+ error: {
1638
+ tag: "InvalidGitHubResponse",
1639
+ message: "GitHub returned an invalid pull request response"
1640
+ }
1641
+ };
1642
+ var pullRequestNotFound = {
1643
+ ok: false,
1644
+ error: {
1645
+ tag: "PullRequestNotFound",
1646
+ message: "Pull request does not exist or is not accessible"
1647
+ }
1648
+ };
1649
+ var githubBatchLimitExceeded = {
1650
+ ok: false,
1651
+ error: {
1652
+ tag: "GitHubBatchLimitExceeded",
1653
+ limit: 20,
1654
+ message: "GitHub batch cannot contain more than 20 pull requests"
1655
+ }
1656
+ };
1657
+ var maximumPullRequestsPerBatch = 20;
1658
+ var maximumCheckContextsPerPage = 100;
1659
+ var statusContextOnlyFields = ["context", "state", "createdAt"];
1660
+ var checkRunOnlyFields = ["name", "status", "conclusion", "checkSuite"];
1661
+ var checkContextSelection = `nodes { __typename ... on StatusContext { id context state createdAt } ... on CheckRun { id name status conclusion checkSuite { id createdAt app { id } workflowRun { event runNumber runAttempt workflow { id } } } } } totalCount pageInfo { hasNextPage endCursor }`;
1662
+ var pullRequestSelection = `__typename ... on PullRequest { title state url mergedAt mergeable mergeStateStatus baseRef { branchProtectionRule { requiresStatusChecks requiresStrictStatusChecks } refUpdateRule { requiredStatusCheckContexts } rules(first: 100) { nodes { parameters { __typename ... on RequiredStatusChecksParameters { strictRequiredStatusChecksPolicy requiredStatusChecks { context } } } } totalCount pageInfo { hasNextPage } } } statusCheckRollup { contexts(first: ${maximumCheckContextsPerPage}) { ${checkContextSelection} } } }`;
1663
+ var continuationQuery = `query PullRequestContexts($url: URI!, $cursor: String!) { resource(url: $url) { __typename ... on PullRequest { url statusCheckRollup { contexts(first: ${maximumCheckContextsPerPage}, after: $cursor) { ${checkContextSelection} } } } } }`;
1664
+ function isRecord(value) {
1665
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1666
+ }
1667
+ function parseProcessExecutionFailed(value) {
1668
+ if (!isRecord(value) || value.tag !== "ProcessExecutionFailed" || value.code !== null && typeof value.code !== "string" && typeof value.code !== "number" || typeof value.stderr !== "string" || typeof value.stdout !== "string" || !("cause" in value)) {
1669
+ return;
1670
+ }
1671
+ return {
1672
+ tag: "ProcessExecutionFailed",
1673
+ code: value.code,
1674
+ stderr: value.stderr,
1675
+ stdout: value.stdout,
1676
+ cause: value.cause
1677
+ };
1678
+ }
1679
+ function parseNonBlankString(input) {
1680
+ return typeof input === "string" && input.trim() !== "" ? input : undefined;
1681
+ }
1682
+ function parseDate(input) {
1683
+ if (typeof input !== "string")
1684
+ return;
1685
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:[Zz]|[+-](\d{2}):(\d{2}))$/.exec(input);
1686
+ if (match === null)
1687
+ return;
1688
+ const year = Number(match[1]);
1689
+ const month = Number(match[2]);
1690
+ const day = Number(match[3]);
1691
+ const hour = Number(match[4]);
1692
+ const minute = Number(match[5]);
1693
+ const second = Number(match[6]);
1694
+ const offsetHour = Number(match[8] ?? 0);
1695
+ const offsetMinute = Number(match[9] ?? 0);
1696
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
1697
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
1698
+ if (daysInMonth === undefined || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
1699
+ return;
1700
+ }
1701
+ if (Number.isNaN(new Date(input).valueOf()))
1702
+ return;
1703
+ const epochSeconds = new Date(input.replace(/\.\d+/, "")).valueOf() / 1000;
1704
+ if (!Number.isInteger(epochSeconds))
1705
+ return;
1706
+ return {
1707
+ epochSeconds,
1708
+ fractionalSeconds: (match[7] ?? "").replace(/0+$/, "")
1709
+ };
1710
+ }
1711
+ function compareTimestamps(left, right) {
1712
+ if (left.epochSeconds < right.epochSeconds)
1713
+ return -1;
1714
+ if (left.epochSeconds > right.epochSeconds)
1715
+ return 1;
1716
+ const width = Math.max(left.fractionalSeconds.length, right.fractionalSeconds.length);
1717
+ const leftFraction = left.fractionalSeconds.padEnd(width, "0");
1718
+ const rightFraction = right.fractionalSeconds.padEnd(width, "0");
1719
+ if (leftFraction < rightFraction)
1720
+ return -1;
1721
+ if (leftFraction > rightFraction)
1722
+ return 1;
1723
+ return 0;
1724
+ }
1725
+ function parseStatusContextState(input) {
1726
+ switch (input) {
1727
+ case "EXPECTED":
1728
+ case "PENDING":
1729
+ case "SUCCESS":
1730
+ case "ERROR":
1731
+ case "FAILURE":
1732
+ return input;
1733
+ default:
1734
+ return;
1735
+ }
1736
+ }
1737
+ function parseCheckRunStatus(input) {
1738
+ switch (input) {
1739
+ case "REQUESTED":
1740
+ case "QUEUED":
1741
+ case "IN_PROGRESS":
1742
+ case "COMPLETED":
1743
+ case "WAITING":
1744
+ case "PENDING":
1745
+ return input;
1746
+ default:
1747
+ return;
1748
+ }
1749
+ }
1750
+ function parseCheckRunConclusion(input) {
1751
+ switch (input) {
1752
+ case null:
1753
+ case "SUCCESS":
1754
+ case "FAILURE":
1755
+ case "CANCELLED":
1756
+ case "TIMED_OUT":
1757
+ case "ACTION_REQUIRED":
1758
+ case "STARTUP_FAILURE":
1759
+ case "STALE":
1760
+ case "NEUTRAL":
1761
+ case "SKIPPED":
1762
+ return input;
1763
+ default:
1764
+ return;
1765
+ }
1766
+ }
1767
+ function parseStatusContext(input) {
1768
+ const id = parseNonBlankString(input.id);
1769
+ const context = parseNonBlankString(input.context);
1770
+ const state = parseStatusContextState(input.state);
1771
+ const createdAt = parseDate(input.createdAt);
1772
+ if (id === undefined || context === undefined || state === undefined || createdAt === undefined) {
1773
+ return invalidGitHubResponse;
1774
+ }
1775
+ return { ok: true, value: { tag: "StatusContext", id, context, state, createdAt } };
1776
+ }
1777
+ function parseCheckRun(input) {
1778
+ const id = parseNonBlankString(input.id);
1779
+ const name = parseNonBlankString(input.name);
1780
+ const status = parseCheckRunStatus(input.status);
1781
+ const conclusion = parseCheckRunConclusion(input.conclusion);
1782
+ if (id === undefined || name === undefined || status === undefined || conclusion === undefined || status !== "COMPLETED" && conclusion !== null || !isRecord(input.checkSuite)) {
1783
+ return invalidGitHubResponse;
1784
+ }
1785
+ const suiteId = parseNonBlankString(input.checkSuite.id);
1786
+ const suiteCreatedAt = parseDate(input.checkSuite.createdAt);
1787
+ if (suiteId === undefined || suiteCreatedAt === undefined)
1788
+ return invalidGitHubResponse;
1789
+ let sourceIdentity;
1790
+ if (input.checkSuite.app === null) {
1791
+ sourceIdentity = ["suite", suiteId];
1792
+ } else {
1793
+ if (!isRecord(input.checkSuite.app))
1794
+ return invalidGitHubResponse;
1795
+ const appId = parseNonBlankString(input.checkSuite.app.id);
1796
+ if (appId === undefined)
1797
+ return invalidGitHubResponse;
1798
+ sourceIdentity = ["app", appId];
1799
+ }
1800
+ let workflowRun;
1801
+ if (input.checkSuite.workflowRun === null) {
1802
+ workflowRun = undefined;
1803
+ } else {
1804
+ if (!isRecord(input.checkSuite.workflowRun) || !isRecord(input.checkSuite.workflowRun.workflow)) {
1805
+ return invalidGitHubResponse;
1806
+ }
1807
+ const event = parseNonBlankString(input.checkSuite.workflowRun.event);
1808
+ const workflowId = parseNonBlankString(input.checkSuite.workflowRun.workflow.id);
1809
+ const runNumber = input.checkSuite.workflowRun.runNumber;
1810
+ const runAttempt = input.checkSuite.workflowRun.runAttempt;
1811
+ if (event === undefined || workflowId === undefined || !Number.isInteger(runNumber) || Number(runNumber) <= 0 || !Number.isInteger(runAttempt) || Number(runAttempt) <= 0) {
1812
+ return invalidGitHubResponse;
1813
+ }
1814
+ workflowRun = { event, workflowId, runNumber: Number(runNumber), runAttempt: Number(runAttempt) };
1815
+ }
1816
+ return {
1817
+ ok: true,
1818
+ value: { tag: "CheckRun", id, name, status, conclusion, suiteId, suiteCreatedAt, sourceIdentity, workflowRun }
1819
+ };
1820
+ }
1821
+ function parseCheckContexts(input) {
1822
+ if (!isRecord(input) || !Number.isInteger(input.totalCount) || Number(input.totalCount) < 0 || !isRecord(input.pageInfo) || typeof input.pageInfo.hasNextPage !== "boolean" || input.pageInfo.endCursor !== null && typeof input.pageInfo.endCursor !== "string") {
1823
+ return invalidGitHubResponse;
1824
+ }
1825
+ const nodes = input.nodes === null && input.totalCount === 0 ? [] : input.nodes;
1826
+ if (!Array.isArray(nodes) || nodes.length > maximumCheckContextsPerPage || nodes.length > Number(input.totalCount)) {
1827
+ return invalidGitHubResponse;
1828
+ }
1829
+ const contexts = [];
1830
+ const ids = new Set;
1831
+ for (const node of nodes) {
1832
+ if (!isRecord(node))
1833
+ return invalidGitHubResponse;
1834
+ let parsed;
1835
+ switch (node.__typename) {
1836
+ case "StatusContext":
1837
+ if (checkRunOnlyFields.some((field) => (field in node)))
1838
+ return invalidGitHubResponse;
1839
+ parsed = parseStatusContext(node);
1840
+ break;
1841
+ case "CheckRun":
1842
+ if (statusContextOnlyFields.some((field) => (field in node)))
1843
+ return invalidGitHubResponse;
1844
+ parsed = parseCheckRun(node);
1845
+ break;
1846
+ default:
1847
+ return invalidGitHubResponse;
1848
+ }
1849
+ if (!parsed.ok || ids.has(parsed.value.id))
1850
+ return invalidGitHubResponse;
1851
+ ids.add(parsed.value.id);
1852
+ contexts.push(parsed.value);
1853
+ }
1854
+ const nextCursor = input.pageInfo.hasNextPage ? parseNonBlankString(input.pageInfo.endCursor) : undefined;
1855
+ if (input.pageInfo.hasNextPage && nextCursor === undefined)
1856
+ return invalidGitHubResponse;
1857
+ if (nextCursor !== undefined && contexts.length === 0)
1858
+ return invalidGitHubResponse;
1859
+ return {
1860
+ ok: true,
1861
+ value: {
1862
+ contexts,
1863
+ totalCount: Number(input.totalCount),
1864
+ ...nextCursor === undefined ? {} : { nextCursor }
1865
+ }
1866
+ };
1867
+ }
1868
+ function classifyStatusContext(state) {
1869
+ switch (state) {
1870
+ case "ERROR":
1871
+ case "FAILURE":
1872
+ return "failed";
1873
+ case "EXPECTED":
1874
+ case "PENDING":
1875
+ return "pending";
1876
+ case "SUCCESS":
1877
+ return "passed";
1878
+ default:
1879
+ return casesHandled(state);
1880
+ }
1881
+ }
1882
+ function classifyCheckRun(checkRun) {
1883
+ if (checkRun.status !== "COMPLETED")
1884
+ return "pending";
1885
+ switch (checkRun.conclusion) {
1886
+ case "FAILURE":
1887
+ case "CANCELLED":
1888
+ case "TIMED_OUT":
1889
+ case "ACTION_REQUIRED":
1890
+ case "STARTUP_FAILURE":
1891
+ case "STALE":
1892
+ return "failed";
1893
+ case "SUCCESS":
1894
+ return "passed";
1895
+ case "NEUTRAL":
1896
+ case "SKIPPED":
1897
+ case null:
1898
+ return "ignored";
1899
+ default:
1900
+ return casesHandled(checkRun.conclusion);
1901
+ }
1902
+ }
1903
+ function classifyContexts(contexts) {
1904
+ const statusContexts = new Map;
1905
+ const workflowChecks = new Map;
1906
+ const nonWorkflowChecks = new Map;
1907
+ for (const context of contexts) {
1908
+ if (context.tag === "StatusContext") {
1909
+ const identity2 = context.context.toLowerCase();
1910
+ const existing2 = statusContexts.get(identity2);
1911
+ const bucket2 = classifyStatusContext(context.state);
1912
+ const ordering2 = existing2 === undefined ? 1 : compareTimestamps(context.createdAt, existing2.createdAt);
1913
+ if (ordering2 > 0) {
1914
+ statusContexts.set(identity2, { createdAt: context.createdAt, buckets: [bucket2] });
1915
+ } else if (ordering2 === 0 && existing2 !== undefined) {
1916
+ existing2.buckets.push(bucket2);
1917
+ }
1918
+ continue;
1919
+ }
1920
+ const bucket = classifyCheckRun(context);
1921
+ if (context.workflowRun !== undefined) {
1922
+ const identity2 = JSON.stringify([
1923
+ "workflow",
1924
+ context.sourceIdentity,
1925
+ context.workflowRun.workflowId,
1926
+ context.workflowRun.event,
1927
+ context.name
1928
+ ]);
1929
+ const existing2 = workflowChecks.get(identity2);
1930
+ const isNewer = existing2 === undefined || context.workflowRun.runNumber > existing2.runNumber || context.workflowRun.runNumber === existing2.runNumber && context.workflowRun.runAttempt > existing2.runAttempt;
1931
+ if (isNewer) {
1932
+ workflowChecks.set(identity2, {
1933
+ runNumber: context.workflowRun.runNumber,
1934
+ runAttempt: context.workflowRun.runAttempt,
1935
+ buckets: [bucket]
1936
+ });
1937
+ } else if (context.workflowRun.runNumber === existing2.runNumber && context.workflowRun.runAttempt === existing2.runAttempt) {
1938
+ existing2.buckets.push(bucket);
1939
+ }
1940
+ continue;
1941
+ }
1942
+ const identity = JSON.stringify(["check", context.sourceIdentity, context.name]);
1943
+ const existing = nonWorkflowChecks.get(identity);
1944
+ const ordering = existing === undefined ? 1 : compareTimestamps(context.suiteCreatedAt, existing.suiteCreatedAt);
1945
+ if (ordering > 0) {
1946
+ nonWorkflowChecks.set(identity, { suiteCreatedAt: context.suiteCreatedAt, buckets: [bucket] });
1947
+ } else if (ordering === 0 && existing !== undefined) {
1948
+ existing.buckets.push(bucket);
1949
+ }
1950
+ }
1951
+ const buckets = new Set;
1952
+ for (const selection of [...statusContexts.values(), ...workflowChecks.values(), ...nonWorkflowChecks.values()]) {
1953
+ for (const bucket of selection.buckets)
1954
+ buckets.add(bucket);
1955
+ }
1956
+ if (buckets.has("failed"))
1957
+ return "failed";
1958
+ if (buckets.has("pending"))
1959
+ return "pending";
1960
+ if (buckets.has("passed"))
1961
+ return "passed";
1962
+ return "none";
1963
+ }
1964
+ function parseStatusCheckRollup(input) {
1965
+ if (input === null)
1966
+ return { ok: true, value: null };
1967
+ if (!isRecord(input))
1968
+ return invalidGitHubResponse;
1969
+ return parseCheckContexts(input.contexts);
1970
+ }
1971
+ function samePullRequest(left, right) {
1972
+ return left.number === right.number && left.owner.toLowerCase() === right.owner.toLowerCase() && left.repository.toLowerCase() === right.repository.toLowerCase();
1973
+ }
1974
+ function parseMergeability(input) {
1975
+ switch (input) {
1976
+ case "MERGEABLE":
1977
+ return { ok: true, value: "mergeable" };
1978
+ case "CONFLICTING":
1979
+ return { ok: true, value: "conflicting" };
1980
+ case "UNKNOWN":
1981
+ return { ok: true, value: "unknown" };
1982
+ default:
1983
+ return invalidGitHubResponse;
1984
+ }
1985
+ }
1986
+ function parseMergeStateStatus(input) {
1987
+ switch (input) {
1988
+ case "BEHIND":
1989
+ return { ok: true, value: "behind" };
1990
+ case "BLOCKED":
1991
+ case "CLEAN":
1992
+ case "DIRTY":
1993
+ case "DRAFT":
1994
+ case "HAS_HOOKS":
1995
+ case "UNKNOWN":
1996
+ case "UNSTABLE":
1997
+ return { ok: true, value: "other" };
1998
+ default:
1999
+ return invalidGitHubResponse;
2000
+ }
2001
+ }
2002
+ function parseRequiredStatusChecks(input) {
2003
+ if (!Array.isArray(input))
2004
+ return invalidGitHubResponse;
2005
+ for (const check of input) {
2006
+ if (!isRecord(check) || typeof check.context !== "string" || check.context.trim() === "") {
2007
+ return invalidGitHubResponse;
2008
+ }
2009
+ }
2010
+ return { ok: true, value: input.length > 0 };
2011
+ }
2012
+ function parseRules(input) {
2013
+ if (!isRecord(input) || !Number.isInteger(input.totalCount) || Number(input.totalCount) < 0 || !isRecord(input.pageInfo) || typeof input.pageInfo.hasNextPage !== "boolean") {
2014
+ return invalidGitHubResponse;
2015
+ }
2016
+ const nodes = input.nodes === null && input.totalCount === 0 ? [] : input.nodes;
2017
+ if (!Array.isArray(nodes) || nodes.length > Number(input.totalCount))
2018
+ return invalidGitHubResponse;
2019
+ if (input.pageInfo.hasNextPage ? nodes.length >= Number(input.totalCount) : nodes.length !== input.totalCount) {
2020
+ return invalidGitHubResponse;
2021
+ }
2022
+ let strict = false;
2023
+ for (const node of nodes) {
2024
+ if (!isRecord(node) || !(node.parameters === null || isRecord(node.parameters))) {
2025
+ return invalidGitHubResponse;
2026
+ }
2027
+ if (node.parameters === null)
2028
+ continue;
2029
+ if (typeof node.parameters.__typename !== "string")
2030
+ return invalidGitHubResponse;
2031
+ if (node.parameters.__typename !== "RequiredStatusChecksParameters")
2032
+ continue;
2033
+ if (typeof node.parameters.strictRequiredStatusChecksPolicy !== "boolean")
2034
+ return invalidGitHubResponse;
2035
+ const requiredChecks = parseRequiredStatusChecks(node.parameters.requiredStatusChecks);
2036
+ if (!requiredChecks.ok)
2037
+ return requiredChecks;
2038
+ if (node.parameters.strictRequiredStatusChecksPolicy && requiredChecks.value)
2039
+ strict = true;
2040
+ }
2041
+ return { ok: true, value: { strict, incomplete: input.pageInfo.hasNextPage } };
2042
+ }
2043
+ function parseRefUpdateRule(input) {
2044
+ if (input === null)
2045
+ return { ok: true, value: false };
2046
+ if (!isRecord(input))
2047
+ return invalidGitHubResponse;
2048
+ if (input.requiredStatusCheckContexts === null)
2049
+ return { ok: true, value: false };
2050
+ if (!Array.isArray(input.requiredStatusCheckContexts))
2051
+ return invalidGitHubResponse;
2052
+ for (const context of input.requiredStatusCheckContexts) {
2053
+ if (typeof context !== "string" || context.trim() === "")
2054
+ return invalidGitHubResponse;
2055
+ }
2056
+ return { ok: true, value: input.requiredStatusCheckContexts.length > 0 };
2057
+ }
2058
+ function parseUpdatePolicy(input) {
2059
+ if (input === null)
2060
+ return { ok: true, value: { strict: false, incomplete: false } };
2061
+ if (!isRecord(input))
2062
+ return invalidGitHubResponse;
2063
+ const refUpdateHasRequiredChecks = parseRefUpdateRule(input.refUpdateRule);
2064
+ if (!refUpdateHasRequiredChecks.ok)
2065
+ return refUpdateHasRequiredChecks;
2066
+ let branchProtectionIsStrict = false;
2067
+ if (input.branchProtectionRule !== null) {
2068
+ if (!isRecord(input.branchProtectionRule) || typeof input.branchProtectionRule.requiresStatusChecks !== "boolean" || typeof input.branchProtectionRule.requiresStrictStatusChecks !== "boolean") {
2069
+ return invalidGitHubResponse;
2070
+ }
2071
+ branchProtectionIsStrict = input.branchProtectionRule.requiresStatusChecks && input.branchProtectionRule.requiresStrictStatusChecks;
2072
+ }
2073
+ const rules = parseRules(input.rules);
2074
+ if (!rules.ok)
2075
+ return rules;
2076
+ return {
2077
+ ok: true,
2078
+ value: {
2079
+ strict: branchProtectionIsStrict || rules.value.strict,
2080
+ incomplete: rules.value.incomplete || input.branchProtectionRule === null && refUpdateHasRequiredChecks.value
2081
+ }
2082
+ };
2083
+ }
2084
+ function parseBlocker(mergeStateStatusInput, baseRefInput) {
2085
+ const mergeStateStatus = parseMergeStateStatus(mergeStateStatusInput);
2086
+ if (!mergeStateStatus.ok)
2087
+ return mergeStateStatus;
2088
+ if (mergeStateStatus.value !== "behind")
2089
+ return { ok: true, value: "none" };
2090
+ const updatePolicy = parseUpdatePolicy(baseRefInput);
2091
+ if (!updatePolicy.ok)
2092
+ return updatePolicy;
2093
+ if (updatePolicy.value.strict)
2094
+ return { ok: true, value: "behind" };
2095
+ return updatePolicy.value.incomplete ? invalidGitHubResponse : { ok: true, value: "none" };
2096
+ }
2097
+ function parsePullRequestMetadata(input, pullRequest) {
2098
+ if (!isRecord(input) || input.__typename !== "PullRequest" || typeof input.title !== "string" || input.title.trim() === "") {
2099
+ return invalidGitHubResponse;
2100
+ }
2101
+ if (input.state !== "OPEN" && input.state !== "CLOSED" && input.state !== "MERGED") {
2102
+ return invalidGitHubResponse;
2103
+ }
2104
+ if (input.mergedAt !== null && typeof input.mergedAt !== "string")
2105
+ return invalidGitHubResponse;
2106
+ if (typeof input.mergedAt === "string" && Number.isNaN(new Date(input.mergedAt).valueOf())) {
2107
+ return invalidGitHubResponse;
2108
+ }
2109
+ if (input.state === "MERGED" && typeof input.mergedAt !== "string")
2110
+ return invalidGitHubResponse;
2111
+ if (input.state !== "MERGED" && input.mergedAt !== null)
2112
+ return invalidGitHubResponse;
2113
+ if (typeof input.url !== "string")
2114
+ return invalidGitHubResponse;
2115
+ const responseUrl = parsePullRequestUrl(input.url);
2116
+ if (!responseUrl.ok || !samePullRequest(responseUrl.value, pullRequest))
2117
+ return invalidGitHubResponse;
2118
+ const mergeability = parseMergeability(input.mergeable);
2119
+ if (!mergeability.ok)
2120
+ return mergeability;
2121
+ return {
2122
+ ok: true,
2123
+ value: {
2124
+ title: input.title,
2125
+ state: input.state,
2126
+ mergeability: mergeability.value,
2127
+ mergeStateStatus: input.mergeStateStatus,
2128
+ baseRef: input.baseRef
2129
+ }
2130
+ };
2131
+ }
2132
+ function finalizeResponse(metadata, pullRequest, ci) {
2133
+ let state;
2134
+ switch (metadata.state) {
2135
+ case "OPEN": {
2136
+ let blocker = "none";
2137
+ if (metadata.mergeability !== "conflicting" && (ci === "none" || ci === "passed")) {
2138
+ const parsedBlocker = parseBlocker(metadata.mergeStateStatus, metadata.baseRef);
2139
+ if (!parsedBlocker.ok)
2140
+ return parsedBlocker;
2141
+ blocker = parsedBlocker.value;
2142
+ }
2143
+ state = { tag: "Open", ci, mergeability: metadata.mergeability, blocker };
2144
+ break;
2145
+ }
2146
+ case "MERGED":
2147
+ state = { tag: "Merged" };
2148
+ break;
2149
+ case "CLOSED":
2150
+ state = { tag: "Closed" };
2151
+ break;
2152
+ default:
2153
+ return casesHandled(metadata.state);
2154
+ }
2155
+ return {
2156
+ ok: true,
2157
+ value: {
2158
+ tag: "Available",
2159
+ pullRequest,
2160
+ title: metadata.title,
2161
+ state,
2162
+ stale: false
2163
+ }
2164
+ };
2165
+ }
2166
+ function parseInitialPullRequest(input, pullRequest) {
2167
+ if (input === null)
2168
+ return pullRequestNotFound;
2169
+ if (!isRecord(input))
2170
+ return invalidGitHubResponse;
2171
+ const contextPage = parseStatusCheckRollup(input.statusCheckRollup);
2172
+ if (!contextPage.ok)
2173
+ return contextPage;
2174
+ if (contextPage.value !== null && contextPage.value.nextCursor === undefined && contextPage.value.contexts.length !== contextPage.value.totalCount) {
2175
+ return invalidGitHubResponse;
2176
+ }
2177
+ const metadata = parsePullRequestMetadata(input, pullRequest);
2178
+ return metadata.ok ? { ok: true, value: { pullRequest, metadata: metadata.value, contextPage: contextPage.value } } : metadata;
2179
+ }
2180
+ function createBatchQuery(size) {
2181
+ const variables = Array.from({ length: size }, (_, index) => `$url${index}: URI!`).join(", ");
2182
+ const fields = Array.from({ length: size }, (_, index) => `pr${index}: resource(url: $url${index}) { ${pullRequestSelection} }`).join(" ");
2183
+ return `query BatchPullRequests(${variables}) { ${fields} }`;
2184
+ }
2185
+ function parseGraphqlErrorAliases(input, size) {
2186
+ if (input === undefined)
2187
+ return { ok: true, value: new Set };
2188
+ if (!Array.isArray(input))
2189
+ return invalidGitHubResponse;
2190
+ const aliases = new Set;
2191
+ for (const error of input) {
2192
+ if (!isRecord(error) || typeof error.message !== "string" || !Array.isArray(error.path) || typeof error.path[0] !== "string") {
2193
+ return invalidGitHubResponse;
2194
+ }
2195
+ const match = /^pr([0-9]+)$/.exec(error.path[0]);
2196
+ if (match === null)
2197
+ return invalidGitHubResponse;
2198
+ const index = Number(match[1]);
2199
+ if (!Number.isInteger(index) || index < 0 || index >= size)
2200
+ return invalidGitHubResponse;
2201
+ aliases.add(index);
2202
+ }
2203
+ return { ok: true, value: aliases };
2204
+ }
2205
+ function parseBatchResponse(input, pullRequests) {
2206
+ if (!isRecord(input) || !isRecord(input.data))
2207
+ return invalidGitHubResponse;
2208
+ const data = input.data;
2209
+ const errorAliases = parseGraphqlErrorAliases(input.errors, pullRequests.length);
2210
+ if (!errorAliases.ok)
2211
+ return errorAliases;
2212
+ return {
2213
+ ok: true,
2214
+ value: pullRequests.map((pullRequest, index) => errorAliases.value.has(index) ? invalidGitHubResponse : parseInitialPullRequest(data[`pr${index}`], pullRequest))
2215
+ };
2216
+ }
2217
+ function parseContinuationResponse(input, pullRequest) {
2218
+ if (!isRecord(input) || input.errors !== undefined && (!Array.isArray(input.errors) || input.errors.length > 0) || !isRecord(input.data) || !isRecord(input.data.resource)) {
2219
+ return invalidGitHubResponse;
2220
+ }
2221
+ const resource = input.data.resource;
2222
+ if (resource.__typename !== "PullRequest" || typeof resource.url !== "string")
2223
+ return invalidGitHubResponse;
2224
+ const responseUrl = parsePullRequestUrl(resource.url);
2225
+ if (!responseUrl.ok || !samePullRequest(responseUrl.value, pullRequest) || !isRecord(resource.statusCheckRollup)) {
2226
+ return invalidGitHubResponse;
2227
+ }
2228
+ return parseCheckContexts(resource.statusCheckRollup.contexts);
2229
+ }
2230
+ function isCancellation(cause, signal) {
2231
+ if (signal?.aborted)
2232
+ return true;
2233
+ const originalCause = parseProcessExecutionFailed(cause)?.cause ?? cause;
2234
+ return originalCause instanceof Error && originalCause.name === "AbortError";
2235
+ }
2236
+ var authenticationFailureMarkers = ["http 401", "bad credentials", "not logged into", "gh auth login"];
2237
+ function isAuthenticationFailure(failure) {
2238
+ if (failure.code === 4)
2239
+ return true;
2240
+ const stderr = failure.stderr.toLowerCase();
2241
+ return authenticationFailureMarkers.some((marker) => stderr.includes(marker));
2242
+ }
2243
+ function classifyProcessFailure(cause) {
2244
+ const failure = parseProcessExecutionFailed(cause);
2245
+ if (failure?.code === "ENOENT") {
2246
+ return { tag: "GitHubCliMissing", message: "GitHub CLI is not installed", cause };
2247
+ }
2248
+ if (failure && isAuthenticationFailure(failure)) {
2249
+ return { tag: "GitHubAuthenticationRequired", message: "GitHub CLI authentication required", cause };
2250
+ }
2251
+ return { tag: "GitHubUnavailable", message: "GitHub status unavailable", cause };
2252
+ }
2253
+ function processFailureStdout(cause) {
2254
+ if (!isRecord(cause) || typeof cause.stdout !== "string" || cause.stdout.trim() === "")
2255
+ return;
2256
+ return cause.stdout;
2257
+ }
2258
+ async function runAndDecode(runner, args, options) {
2259
+ let stdout;
2260
+ let processFailure;
2261
+ try {
2262
+ const output = await runner("gh", args, options);
2263
+ stdout = output.stdout;
2264
+ } catch (cause) {
2265
+ if (isCancellation(cause, options.signal)) {
2266
+ return {
2267
+ ok: false,
2268
+ error: {
2269
+ tag: "GitHubCancelled",
2270
+ message: "GitHub status request cancelled",
2271
+ cause
2272
+ }
2273
+ };
2274
+ }
2275
+ processFailure = classifyProcessFailure(cause);
2276
+ const partialStdout = processFailureStdout(cause);
2277
+ if (partialStdout === undefined)
2278
+ return { ok: false, error: processFailure };
2279
+ stdout = partialStdout;
2280
+ }
2281
+ let decoded;
2282
+ try {
2283
+ decoded = JSON.parse(stdout);
2284
+ } catch {
2285
+ return processFailure === undefined ? invalidGitHubResponse : { ok: false, error: processFailure };
2286
+ }
2287
+ return { ok: true, value: { decoded, ...processFailure === undefined ? {} : { processFailure } } };
2288
+ }
2289
+ async function continuePullRequest(runner, initial, options) {
2290
+ if (initial.contextPage?.nextCursor === undefined) {
2291
+ const ci = initial.contextPage === null ? "none" : classifyContexts(initial.contextPage.contexts);
2292
+ return { tag: "Item", result: finalizeResponse(initial.metadata, initial.pullRequest, ci) };
2293
+ }
2294
+ const contexts = [...initial.contextPage.contexts];
2295
+ const totalCount = initial.contextPage.totalCount;
2296
+ const contextIds = new Set(contexts.map((context) => context.id));
2297
+ const cursors = new Set([initial.contextPage.nextCursor]);
2298
+ let cursor = initial.contextPage.nextCursor;
2299
+ while (cursor !== undefined) {
2300
+ const args = [
2301
+ "api",
2302
+ "graphql",
2303
+ "--method",
2304
+ "POST",
2305
+ "-f",
2306
+ `query=${continuationQuery}`,
2307
+ "-f",
2308
+ `url=${initial.pullRequest.url}`,
2309
+ "-f",
2310
+ `cursor=${cursor}`
2311
+ ];
2312
+ const output = await runAndDecode(runner, args, options);
2313
+ if (!output.ok) {
2314
+ return output.error.tag === "GitHubCancelled" ? { tag: "Cancelled", error: output.error } : { tag: "Item", result: { ok: false, error: output.error } };
2315
+ }
2316
+ const page = parseContinuationResponse(output.value.decoded, initial.pullRequest);
2317
+ if (!page.ok) {
2318
+ return {
2319
+ tag: "Item",
2320
+ result: { ok: false, error: output.value.processFailure ?? page.error }
2321
+ };
2322
+ }
2323
+ if (page.value.totalCount !== totalCount)
2324
+ return { tag: "Item", result: invalidGitHubResponse };
2325
+ for (const context of page.value.contexts) {
2326
+ if (contextIds.has(context.id))
2327
+ return { tag: "Item", result: invalidGitHubResponse };
2328
+ contextIds.add(context.id);
2329
+ }
2330
+ if (contexts.length + page.value.contexts.length > totalCount) {
2331
+ return { tag: "Item", result: invalidGitHubResponse };
2332
+ }
2333
+ if (page.value.nextCursor !== undefined) {
2334
+ if (cursors.has(page.value.nextCursor))
2335
+ return { tag: "Item", result: invalidGitHubResponse };
2336
+ cursors.add(page.value.nextCursor);
2337
+ }
2338
+ contexts.push(...page.value.contexts);
2339
+ cursor = page.value.nextCursor;
2340
+ }
2341
+ if (contexts.length !== totalCount)
2342
+ return { tag: "Item", result: invalidGitHubResponse };
2343
+ const status = finalizeResponse(initial.metadata, initial.pullRequest, classifyContexts(contexts));
2344
+ return { tag: "Item", result: status };
2345
+ }
2346
+ var execFileRunner = (file, args, options) => new Promise((resolve, reject) => {
2347
+ execFile(file, [...args], {
2348
+ encoding: "utf8",
2349
+ ...options.signal ? { signal: options.signal } : {},
2350
+ ...options.cwd ? { cwd: options.cwd } : {}
2351
+ }, (error, stdout, stderr) => {
2352
+ if (error) {
2353
+ reject({
2354
+ tag: "ProcessExecutionFailed",
2355
+ code: error.code ?? null,
2356
+ stderr,
2357
+ stdout,
2358
+ cause: error
2359
+ });
2360
+ return;
2361
+ }
2362
+ resolve({ stdout });
2363
+ });
2364
+ });
2365
+ function createGitHubClient(runner = execFileRunner) {
2366
+ return {
2367
+ async get(pullRequests, options = {}) {
2368
+ if (pullRequests.length === 0)
2369
+ return { ok: true, value: [] };
2370
+ if (pullRequests.length > maximumPullRequestsPerBatch)
2371
+ return githubBatchLimitExceeded;
2372
+ const query = createBatchQuery(pullRequests.length);
2373
+ const args = ["api", "graphql", "--method", "POST", "-f", `query=${query}`];
2374
+ for (const [index, pullRequest] of pullRequests.entries()) {
2375
+ args.push("-f", `url${index}=${pullRequest.url}`);
2376
+ }
2377
+ const output = await runAndDecode(runner, args, options);
2378
+ if (!output.ok)
2379
+ return output;
2380
+ const parsed = parseBatchResponse(output.value.decoded, pullRequests);
2381
+ if (!parsed.ok)
2382
+ return { ok: false, error: output.value.processFailure ?? parsed.error };
2383
+ const outcomes = await Promise.all(parsed.value.map((item) => {
2384
+ if (!item.ok)
2385
+ return Promise.resolve({ tag: "Item", result: item });
2386
+ return continuePullRequest(runner, item.value, options);
2387
+ }));
2388
+ const batch = [];
2389
+ let cancellation;
2390
+ for (const outcome of outcomes) {
2391
+ if (outcome.tag === "Cancelled")
2392
+ cancellation ??= outcome.error;
2393
+ else
2394
+ batch.push(outcome.result);
2395
+ }
2396
+ return cancellation === undefined ? { ok: true, value: batch } : { ok: false, error: cancellation };
2397
+ }
2398
+ };
2399
+ }
2400
+ var openAppearances = {
2401
+ passed: { tone: "green", label: "checks passed", strikethrough: false },
2402
+ pending: { tone: "yellow", label: "checks pending", strikethrough: false },
2403
+ failed: { tone: "red", label: "checks failed", strikethrough: false },
2404
+ none: { tone: "gray", label: "no checks", strikethrough: false }
2405
+ };
2406
+ var diagnosticLabels = {
2407
+ GitHubCliMissing: "install gh",
2408
+ GitHubAuthenticationRequired: "run gh auth login",
2409
+ GitHubUnavailable: "GitHub unavailable",
2410
+ PullRequestNotFound: "not found or inaccessible",
2411
+ InvalidGitHubResponse: "invalid GitHub response"
2412
+ };
2413
+ function stateAppearance(state) {
2414
+ switch (state.tag) {
2415
+ case "Open": {
2416
+ switch (state.mergeability) {
2417
+ case "conflicting":
2418
+ return { tone: "red", label: "merge conflict", strikethrough: false };
2419
+ case "mergeable":
2420
+ case "unknown":
2421
+ switch (state.ci) {
2422
+ case "failed":
2423
+ case "pending":
2424
+ return openAppearances[state.ci];
2425
+ case "none":
2426
+ case "passed":
2427
+ switch (state.blocker) {
2428
+ case "behind":
2429
+ return { tone: "yellow", label: "branch behind", strikethrough: false };
2430
+ case "none":
2431
+ return openAppearances[state.ci];
2432
+ default:
2433
+ return casesHandled(state.blocker);
2434
+ }
2435
+ default:
2436
+ return casesHandled(state.ci);
2437
+ }
2438
+ default:
2439
+ return casesHandled(state.mergeability);
2440
+ }
2441
+ }
2442
+ case "Merged":
2443
+ return { tone: "purple", label: "merged", strikethrough: true };
2444
+ case "Closed":
2445
+ return { tone: "red", label: "closed", strikethrough: true };
2446
+ default:
2447
+ return casesHandled(state);
2448
+ }
2449
+ }
2450
+ function statusAppearance(status) {
2451
+ if (status.tag === "Unavailable") {
2452
+ return {
2453
+ tone: "gray",
2454
+ label: status.diagnostic === undefined ? "status unavailable" : diagnosticLabels[status.diagnostic],
2455
+ strikethrough: false
2456
+ };
2457
+ }
2458
+ const appearance = stateAppearance(status.state);
2459
+ return status.stale ? { ...appearance, label: `${appearance.label} (stale; ${diagnosticLabels[status.diagnostic]})` } : appearance;
2460
+ }
2461
+
2462
+ // src/attach.ts
2463
+ async function attachPullRequest(dependencies, sessionID, pullRequest, options = {}) {
2464
+ return dependencies.store.attach(sessionID, pullRequest, {
2465
+ async validate() {
2466
+ const batch = await dependencies.github.get([pullRequest], options);
2467
+ if (!batch.ok)
2468
+ return batch;
2469
+ const item = batch.value[0];
2470
+ if (item === undefined)
2471
+ throw new Error("GitHub client omitted the requested pull request");
2472
+ if (!item.ok)
2473
+ return item;
2474
+ return { ok: true, value: undefined };
2475
+ }
2476
+ });
2477
+ }
2478
+ var invalidPullRequestInput = {
2479
+ ok: false,
2480
+ error: {
2481
+ tag: "InvalidPullRequestInput",
2482
+ message: "Expected https://github.com/<owner>/<repository>/pull/<positive-integer>, github.com/<owner>/<repository>/pull/<positive-integer>, or a positive pull request number"
2483
+ }
2484
+ };
2485
+ var repositoryResolutionFailed = {
2486
+ tag: "RepositoryResolutionFailed",
2487
+ message: "Unable to resolve the current GitHub repository with gh; attach with a full URL instead"
2488
+ };
2489
+ var repositoryResolutionCancelled = {
2490
+ ok: false,
2491
+ error: { tag: "RepositoryResolutionCancelled" }
2492
+ };
2493
+ function isCancellation2(cause, signal) {
2494
+ if (signal?.aborted)
2495
+ return true;
2496
+ return cause instanceof Error && cause.name === "AbortError";
2497
+ }
2498
+ function parseRepositoryPullRequest(stdout, number) {
2499
+ let decoded;
2500
+ try {
2501
+ decoded = JSON.parse(stdout);
2502
+ } catch {
2503
+ return { ok: false, error: repositoryResolutionFailed };
2504
+ }
2505
+ if (decoded === null || typeof decoded !== "object" || !("url" in decoded) || typeof decoded.url !== "string" || decoded.url === "") {
2506
+ return { ok: false, error: repositoryResolutionFailed };
2507
+ }
2508
+ const repositoryUrl = decoded.url.endsWith("/") ? decoded.url.slice(0, -1) : decoded.url;
2509
+ const pullRequest = parsePullRequestUrl(`${repositoryUrl}/pull/${number}`);
2510
+ return pullRequest.ok ? pullRequest : { ok: false, error: repositoryResolutionFailed };
2511
+ }
2512
+ async function resolvePullRequestInput(input, options) {
2513
+ const direct = parsePullRequestUrl(input);
2514
+ if (direct.ok)
2515
+ return direct;
2516
+ if (input.trim() !== input || !/^\d+$/.test(input))
2517
+ return invalidPullRequestInput;
2518
+ const number = Number(input);
2519
+ if (!Number.isSafeInteger(number) || number <= 0)
2520
+ return invalidPullRequestInput;
2521
+ let stdout;
2522
+ try {
2523
+ const result = await (options.runner ?? execFileRunner)("gh", ["repo", "view", "--json", "url"], {
2524
+ cwd: options.directory,
2525
+ ...options.signal ? { signal: options.signal } : {}
2526
+ });
2527
+ stdout = result.stdout;
2528
+ } catch (cause) {
2529
+ if (isCancellation2(cause, options.signal))
2530
+ return repositoryResolutionCancelled;
2531
+ return { ok: false, error: { ...repositoryResolutionFailed, cause } };
2532
+ }
2533
+ return parseRepositoryPullRequest(stdout, number);
2534
+ }
2535
+
1626
2536
  // src/state.ts
2537
+ var import_proper_lockfile = __toESM(require_proper_lockfile(), 1);
2538
+ import { createHash, randomUUID } from "crypto";
2539
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
2540
+ import { homedir } from "os";
2541
+ import { join } from "path";
1627
2542
  var maximumPullRequestsPerSession = 20;
1628
2543
  var invalidStateFile = {
1629
2544
  ok: false,
@@ -1637,7 +2552,7 @@ var lockUpdateMilliseconds = 2000;
1637
2552
  function stateUnavailable(operation, message, cause) {
1638
2553
  return { tag: "StateUnavailable", operation, message, cause };
1639
2554
  }
1640
- function isRecord(value) {
2555
+ function isRecord2(value) {
1641
2556
  return value !== null && typeof value === "object" && !Array.isArray(value);
1642
2557
  }
1643
2558
  function hasExactKeys(value, keys) {
@@ -1645,7 +2560,7 @@ function hasExactKeys(value, keys) {
1645
2560
  return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
1646
2561
  }
1647
2562
  function parseState(input) {
1648
- if (!isRecord(input) || !hasExactKeys(input, ["version", "pullRequests"]))
2563
+ if (!isRecord2(input) || !hasExactKeys(input, ["version", "pullRequests"]))
1649
2564
  return invalidStateFile;
1650
2565
  if (input.version !== 1 || !Array.isArray(input.pullRequests))
1651
2566
  return invalidStateFile;
@@ -1654,7 +2569,7 @@ function parseState(input) {
1654
2569
  const attachments = [];
1655
2570
  const seen = new Set;
1656
2571
  for (const item of input.pullRequests) {
1657
- if (!isRecord(item) || !hasExactKeys(item, ["url", "attachedAt"]))
2572
+ if (!isRecord2(item) || !hasExactKeys(item, ["url", "attachedAt"]))
1658
2573
  return invalidStateFile;
1659
2574
  if (typeof item.url !== "string" || typeof item.attachedAt !== "string")
1660
2575
  return invalidStateFile;
@@ -1682,12 +2597,30 @@ function defaultStateDirectory(environment = process.env, home = homedir()) {
1682
2597
  function createStateStore(options = {}) {
1683
2598
  const directory = options.directory ?? defaultStateDirectory();
1684
2599
  const now = options.now ?? (() => new Date);
2600
+ const lockStateFile = options.lock ?? import_proper_lockfile.lock;
2601
+ const attachTails = new Map;
2602
+ async function enqueueAttach(sessionID, operation) {
2603
+ const previous = attachTails.get(sessionID) ?? Promise.resolve();
2604
+ let release;
2605
+ const current = new Promise((resolve) => {
2606
+ release = resolve;
2607
+ });
2608
+ attachTails.set(sessionID, current);
2609
+ await previous;
2610
+ try {
2611
+ return await operation();
2612
+ } finally {
2613
+ release();
2614
+ if (attachTails.get(sessionID) === current)
2615
+ attachTails.delete(sessionID);
2616
+ }
2617
+ }
1685
2618
  async function acquireLock(sessionID) {
1686
2619
  const stateFile = join(directory, fileName(sessionID));
1687
2620
  let compromised;
1688
2621
  try {
1689
2622
  await mkdir(directory, { recursive: true });
1690
- const release = await import_proper_lockfile.lock(stateFile, {
2623
+ const release = await lockStateFile(stateFile, {
1691
2624
  realpath: false,
1692
2625
  stale: lockStaleMilliseconds,
1693
2626
  update: lockUpdateMilliseconds,
@@ -1787,9 +2720,29 @@ function createStateStore(options = {}) {
1787
2720
  };
1788
2721
  }
1789
2722
  }
1790
- return {
1791
- list: read,
1792
- async attach(sessionID, pullRequest) {
2723
+ async function attach(sessionID, pullRequest, attachOptions = {}) {
2724
+ return enqueueAttach(sessionID, async () => {
2725
+ if (attachOptions.validate !== undefined) {
2726
+ const current = await read(sessionID);
2727
+ if (!current.ok)
2728
+ return current;
2729
+ if (current.value.some((attachment) => attachment.pullRequest.url === pullRequest.url)) {
2730
+ return { ok: true, value: "already_attached" };
2731
+ }
2732
+ if (current.value.length >= maximumPullRequestsPerSession) {
2733
+ return {
2734
+ ok: false,
2735
+ error: {
2736
+ tag: "AttachmentLimitReached",
2737
+ limit: maximumPullRequestsPerSession,
2738
+ message: "A session can track at most 20 pull requests"
2739
+ }
2740
+ };
2741
+ }
2742
+ const validation = await attachOptions.validate();
2743
+ if (!validation.ok)
2744
+ return validation;
2745
+ }
1793
2746
  return withLock(sessionID, async () => {
1794
2747
  const current = await read(sessionID);
1795
2748
  if (!current.ok)
@@ -1812,7 +2765,11 @@ function createStateStore(options = {}) {
1812
2765
  return written;
1813
2766
  return { ok: true, value: "added" };
1814
2767
  });
1815
- },
2768
+ });
2769
+ }
2770
+ return {
2771
+ list: read,
2772
+ attach,
1816
2773
  async detach(sessionID, pullRequest) {
1817
2774
  return withLock(sessionID, async () => {
1818
2775
  const current = await read(sessionID);
@@ -1891,7 +2848,7 @@ function formatReferenceList(references) {
1891
2848
  return references.join(" and ");
1892
2849
  return `${references.slice(0, -1).join(", ")}, and ${references.at(-1)}`;
1893
2850
  }
1894
- function createServerHooks(store) {
2851
+ function createServerHooks(store, github = createGitHubClient()) {
1895
2852
  return {
1896
2853
  async event({ event }) {
1897
2854
  if (event.type !== "session.deleted")
@@ -1901,16 +2858,32 @@ function createServerHooks(store) {
1901
2858
  throw toToolError(result.error);
1902
2859
  },
1903
2860
  tool: {
2861
+ pr_list: tool({
2862
+ description: "List pull requests attached to the current OpenCode session.",
2863
+ args: {},
2864
+ async execute(_args, context) {
2865
+ const result = await store.list(context.sessionID);
2866
+ if (!result.ok)
2867
+ throw toToolError(result.error);
2868
+ if (result.value.length === 0)
2869
+ return "No pull requests are attached to this session.";
2870
+ return `Attached pull requests:
2871
+ ${result.value.map((attachment) => `- ${attachment.pullRequest.url}`).join(`
2872
+ `)}`;
2873
+ }
2874
+ }),
1904
2875
  pr_attach: tool({
1905
- description: "Attach a canonical GitHub pull request URL to the current OpenCode session.",
2876
+ description: "Attach a GitHub pull request URL to the current OpenCode session.",
1906
2877
  args: {
1907
- url: tool.schema.string().describe("A https://github.com/<owner>/<repository>/pull/<number> URL")
2878
+ url: tool.schema.string().describe("A https://github.com/<owner>/<repository>/pull/<number> or github.com/<owner>/<repository>/pull/<number> URL")
1908
2879
  },
1909
2880
  async execute(args, context) {
1910
2881
  const pullRequest = parsePullRequestUrl(args.url);
1911
2882
  if (!pullRequest.ok)
1912
2883
  throw new PrToolError(pullRequest.error.tag, pullRequest.error.message);
1913
- const result = await store.attach(context.sessionID, pullRequest.value);
2884
+ const result = await attachPullRequest({ store, github }, context.sessionID, pullRequest.value, {
2885
+ signal: context.abort
2886
+ });
1914
2887
  if (!result.ok)
1915
2888
  throw toToolError(result.error);
1916
2889
  const reference = formatPullRequestRef(pullRequest.value);
@@ -1918,14 +2891,14 @@ function createServerHooks(store) {
1918
2891
  }
1919
2892
  }),
1920
2893
  pr_detach: tool({
1921
- description: "Detach a pull request from the current OpenCode session by positive number or canonical URL.",
2894
+ description: "Detach a pull request from the current OpenCode session by positive number or GitHub URL.",
1922
2895
  args: {
1923
- pull_request: tool.schema.union([tool.schema.number().int().positive().max(Number.MAX_SAFE_INTEGER), tool.schema.string()]).describe("A positive pull request number or https://github.com/<owner>/<repository>/pull/<number> URL")
2896
+ pull_request: tool.schema.union([tool.schema.number().int().positive().max(Number.MAX_SAFE_INTEGER), tool.schema.string()]).describe("https://github.com/owner/repository/pull/123, github.com/owner/repository/pull/123, or 123")
1924
2897
  },
1925
2898
  async execute(args, context) {
1926
2899
  if (typeof args.pull_request === "number") {
1927
2900
  if (!Number.isSafeInteger(args.pull_request) || args.pull_request <= 0) {
1928
- throw new PrToolError("InvalidPullRequestNumber", "Expected a positive pull request number or canonical GitHub URL");
2901
+ throw new PrToolError("InvalidPullRequestNumber", "Expected 123, https://github.com/owner/repository/pull/123, or github.com/owner/repository/pull/123");
1929
2902
  }
1930
2903
  const result2 = await store.detachByNumber(context.sessionID, args.pull_request);
1931
2904
  if (!result2.ok)
@@ -1954,7 +2927,7 @@ function createServerHooks(store) {
1954
2927
  }
1955
2928
  var plugin = {
1956
2929
  id: "opencode-pr-tracker",
1957
- server: async () => createServerHooks(createStateStore())
2930
+ server: async () => createServerHooks(createStateStore(), createGitHubClient())
1958
2931
  };
1959
2932
  var server_default = plugin;
1960
2933
  export {
@@ -1963,4 +2936,4 @@ export {
1963
2936
  PrToolError
1964
2937
  };
1965
2938
 
1966
- //# debugId=7F3A95F9015E656D64756E2164756E21
2939
+ //# debugId=A879251DAA53DE8064756E2164756E21