@hcrosse/opencode-pr-tracker 0.2.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,36 +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
1580
  if (input.includes("\\"))
1578
1581
  return invalidPullRequestUrl;
1579
- if (!input.startsWith("https://"))
1582
+ const candidate = input.slice(0, schemeLessPrefix.length).toLowerCase() === schemeLessPrefix ? `https://${input}` : input;
1583
+ if (!candidate.startsWith("https://"))
1580
1584
  return invalidPullRequestUrl;
1581
- const authorityEnd = input.indexOf("/", "https://".length);
1585
+ const authorityEnd = candidate.indexOf("/", "https://".length);
1582
1586
  if (authorityEnd === -1)
1583
1587
  return invalidPullRequestUrl;
1584
- if (input.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1588
+ if (candidate.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1585
1589
  return invalidPullRequestUrl;
1586
1590
  }
1587
- const rawPath = input.slice(authorityEnd).split(/[?#]/, 1).join("");
1591
+ const rawPath = candidate.slice(authorityEnd).split(/[?#]/, 1).join("");
1588
1592
  for (const segment of rawPath.split("/")) {
1589
1593
  let decoded;
1590
1594
  try {
@@ -1597,7 +1601,7 @@ function parsePullRequestUrl(input) {
1597
1601
  }
1598
1602
  let parsed;
1599
1603
  try {
1600
- parsed = new URL(input);
1604
+ parsed = new URL(candidate);
1601
1605
  } catch {
1602
1606
  return invalidPullRequestUrl;
1603
1607
  }
@@ -1627,7 +1631,914 @@ function formatPullRequestRef(pullRequest) {
1627
1631
  return `${pullRequest.owner}/${pullRequest.repository}#${pullRequest.number}`;
1628
1632
  }
1629
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
+
1630
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";
1631
2542
  var maximumPullRequestsPerSession = 20;
1632
2543
  var invalidStateFile = {
1633
2544
  ok: false,
@@ -1641,7 +2552,7 @@ var lockUpdateMilliseconds = 2000;
1641
2552
  function stateUnavailable(operation, message, cause) {
1642
2553
  return { tag: "StateUnavailable", operation, message, cause };
1643
2554
  }
1644
- function isRecord(value) {
2555
+ function isRecord2(value) {
1645
2556
  return value !== null && typeof value === "object" && !Array.isArray(value);
1646
2557
  }
1647
2558
  function hasExactKeys(value, keys) {
@@ -1649,7 +2560,7 @@ function hasExactKeys(value, keys) {
1649
2560
  return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
1650
2561
  }
1651
2562
  function parseState(input) {
1652
- if (!isRecord(input) || !hasExactKeys(input, ["version", "pullRequests"]))
2563
+ if (!isRecord2(input) || !hasExactKeys(input, ["version", "pullRequests"]))
1653
2564
  return invalidStateFile;
1654
2565
  if (input.version !== 1 || !Array.isArray(input.pullRequests))
1655
2566
  return invalidStateFile;
@@ -1658,7 +2569,7 @@ function parseState(input) {
1658
2569
  const attachments = [];
1659
2570
  const seen = new Set;
1660
2571
  for (const item of input.pullRequests) {
1661
- if (!isRecord(item) || !hasExactKeys(item, ["url", "attachedAt"]))
2572
+ if (!isRecord2(item) || !hasExactKeys(item, ["url", "attachedAt"]))
1662
2573
  return invalidStateFile;
1663
2574
  if (typeof item.url !== "string" || typeof item.attachedAt !== "string")
1664
2575
  return invalidStateFile;
@@ -1686,12 +2597,30 @@ function defaultStateDirectory(environment = process.env, home = homedir()) {
1686
2597
  function createStateStore(options = {}) {
1687
2598
  const directory = options.directory ?? defaultStateDirectory();
1688
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
+ }
1689
2618
  async function acquireLock(sessionID) {
1690
2619
  const stateFile = join(directory, fileName(sessionID));
1691
2620
  let compromised;
1692
2621
  try {
1693
2622
  await mkdir(directory, { recursive: true });
1694
- const release = await import_proper_lockfile.lock(stateFile, {
2623
+ const release = await lockStateFile(stateFile, {
1695
2624
  realpath: false,
1696
2625
  stale: lockStaleMilliseconds,
1697
2626
  update: lockUpdateMilliseconds,
@@ -1791,9 +2720,29 @@ function createStateStore(options = {}) {
1791
2720
  };
1792
2721
  }
1793
2722
  }
1794
- return {
1795
- list: read,
1796
- 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
+ }
1797
2746
  return withLock(sessionID, async () => {
1798
2747
  const current = await read(sessionID);
1799
2748
  if (!current.ok)
@@ -1816,7 +2765,11 @@ function createStateStore(options = {}) {
1816
2765
  return written;
1817
2766
  return { ok: true, value: "added" };
1818
2767
  });
1819
- },
2768
+ });
2769
+ }
2770
+ return {
2771
+ list: read,
2772
+ attach,
1820
2773
  async detach(sessionID, pullRequest) {
1821
2774
  return withLock(sessionID, async () => {
1822
2775
  const current = await read(sessionID);
@@ -1895,7 +2848,7 @@ function formatReferenceList(references) {
1895
2848
  return references.join(" and ");
1896
2849
  return `${references.slice(0, -1).join(", ")}, and ${references.at(-1)}`;
1897
2850
  }
1898
- function createServerHooks(store) {
2851
+ function createServerHooks(store, github = createGitHubClient()) {
1899
2852
  return {
1900
2853
  async event({ event }) {
1901
2854
  if (event.type !== "session.deleted")
@@ -1905,16 +2858,32 @@ function createServerHooks(store) {
1905
2858
  throw toToolError(result.error);
1906
2859
  },
1907
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
+ }),
1908
2875
  pr_attach: tool({
1909
- 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.",
1910
2877
  args: {
1911
- 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")
1912
2879
  },
1913
2880
  async execute(args, context) {
1914
2881
  const pullRequest = parsePullRequestUrl(args.url);
1915
2882
  if (!pullRequest.ok)
1916
2883
  throw new PrToolError(pullRequest.error.tag, pullRequest.error.message);
1917
- 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
+ });
1918
2887
  if (!result.ok)
1919
2888
  throw toToolError(result.error);
1920
2889
  const reference = formatPullRequestRef(pullRequest.value);
@@ -1922,14 +2891,14 @@ function createServerHooks(store) {
1922
2891
  }
1923
2892
  }),
1924
2893
  pr_detach: tool({
1925
- 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.",
1926
2895
  args: {
1927
- 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")
1928
2897
  },
1929
2898
  async execute(args, context) {
1930
2899
  if (typeof args.pull_request === "number") {
1931
2900
  if (!Number.isSafeInteger(args.pull_request) || args.pull_request <= 0) {
1932
- 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");
1933
2902
  }
1934
2903
  const result2 = await store.detachByNumber(context.sessionID, args.pull_request);
1935
2904
  if (!result2.ok)
@@ -1958,7 +2927,7 @@ function createServerHooks(store) {
1958
2927
  }
1959
2928
  var plugin = {
1960
2929
  id: "opencode-pr-tracker",
1961
- server: async () => createServerHooks(createStateStore())
2930
+ server: async () => createServerHooks(createStateStore(), createGitHubClient())
1962
2931
  };
1963
2932
  var server_default = plugin;
1964
2933
  export {
@@ -1967,4 +2936,4 @@ export {
1967
2936
  PrToolError
1968
2937
  };
1969
2938
 
1970
- //# debugId=8B547DD45DB1BAE364756E2164756E21
2939
+ //# debugId=A879251DAA53DE8064756E2164756E21