@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/tui.js CHANGED
@@ -536,8 +536,8 @@ GFS4: `);
536
536
  fs2.createReadStream = createReadStream;
537
537
  fs2.createWriteStream = createWriteStream;
538
538
  var fs$readFile = fs2.readFile;
539
- fs2.readFile = readFile;
540
- function readFile(path, options, cb) {
539
+ fs2.readFile = readFile2;
540
+ function readFile2(path, options, cb) {
541
541
  if (typeof options === "function")
542
542
  cb = options, options = null;
543
543
  return go$readFile(path, options, cb);
@@ -1553,46 +1553,53 @@ var require_proper_lockfile = __commonJS((exports, module) => {
1553
1553
  });
1554
1554
 
1555
1555
  // src/tui.tsx
1556
- import { memo as _$memo } from "@opentui/solid";
1556
+ import { createComponent as _$createComponent4 } from "@opentui/solid";
1557
+
1558
+ // src/feedback-tui.tsx
1559
+ import { createComponent as _$createComponent } from "@opentui/solid";
1560
+ import { effect as _$effect } from "@opentui/solid";
1557
1561
  import { createTextNode as _$createTextNode } from "@opentui/solid";
1558
1562
  import { insertNode as _$insertNode } from "@opentui/solid";
1559
- import { setProp as _$setProp } from "@opentui/solid";
1560
- import { effect as _$effect } from "@opentui/solid";
1561
1563
  import { insert as _$insert } from "@opentui/solid";
1564
+ import { setProp as _$setProp } from "@opentui/solid";
1562
1565
  import { createElement as _$createElement } from "@opentui/solid";
1563
- import { createComponent as _$createComponent } from "@opentui/solid";
1564
- import { TextAttributes } from "@opentui/core";
1565
- import { createSignal, onCleanup } from "solid-js";
1566
-
1567
- // src/github.ts
1568
- import { execFile } from "child_process";
1566
+ import { useKeyboard } from "@opentui/solid";
1567
+ import { createSignal } from "solid-js";
1569
1568
 
1570
1569
  // src/exhaustive.ts
1571
1570
  function casesHandled(value) {
1572
1571
  throw new Error(`Unhandled case: ${String(value)}`);
1573
1572
  }
1574
1573
 
1574
+ // src/github.ts
1575
+ import { execFile } from "child_process";
1576
+
1575
1577
  // src/url.ts
1578
+ var expectedPullRequestUrl = "Expected https://github.com/<owner>/<repository>/pull/<positive-integer> or github.com/<owner>/<repository>/pull/<positive-integer>";
1576
1579
  var invalidPullRequestUrl = {
1577
1580
  ok: false,
1578
1581
  error: {
1579
1582
  tag: "InvalidPullRequestUrl",
1580
- message: "Expected https://github.com/<owner>/<repository>/pull/<positive-integer>"
1583
+ message: expectedPullRequestUrl
1581
1584
  }
1582
1585
  };
1583
1586
  var segmentPattern = /^[A-Za-z0-9._-]+$/;
1587
+ var schemeLessPrefix = "github.com/";
1584
1588
  function parsePullRequestUrl(input) {
1585
- if (input.trim() !== input)
1589
+ if (/\s/.test(input))
1590
+ return invalidPullRequestUrl;
1591
+ if (input.includes("\\"))
1586
1592
  return invalidPullRequestUrl;
1587
- if (!input.startsWith("https://"))
1593
+ const candidate = input.slice(0, schemeLessPrefix.length).toLowerCase() === schemeLessPrefix ? `https://${input}` : input;
1594
+ if (!candidate.startsWith("https://"))
1588
1595
  return invalidPullRequestUrl;
1589
- const authorityEnd = input.indexOf("/", "https://".length);
1596
+ const authorityEnd = candidate.indexOf("/", "https://".length);
1590
1597
  if (authorityEnd === -1)
1591
1598
  return invalidPullRequestUrl;
1592
- if (input.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1599
+ if (candidate.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1593
1600
  return invalidPullRequestUrl;
1594
1601
  }
1595
- const rawPath = input.slice(authorityEnd).split(/[?#]/, 1).join("");
1602
+ const rawPath = candidate.slice(authorityEnd).split(/[?#]/, 1).join("");
1596
1603
  for (const segment of rawPath.split("/")) {
1597
1604
  let decoded;
1598
1605
  try {
@@ -1605,7 +1612,7 @@ function parsePullRequestUrl(input) {
1605
1612
  }
1606
1613
  let parsed;
1607
1614
  try {
1608
- parsed = new URL(input);
1615
+ parsed = new URL(candidate);
1609
1616
  } catch {
1610
1617
  return invalidPullRequestUrl;
1611
1618
  }
@@ -1616,15 +1623,17 @@ function parsePullRequestUrl(input) {
1616
1623
  if (segments.length !== 5 || segments[0] !== "" || segments[3] !== "pull") {
1617
1624
  return invalidPullRequestUrl;
1618
1625
  }
1619
- const owner = segments[1];
1620
- const repository = segments[2];
1626
+ const rawOwner = segments[1];
1627
+ const rawRepository = segments[2];
1621
1628
  const numberText = segments[4];
1622
- if (owner === undefined || repository === undefined || numberText === undefined || !segmentPattern.test(owner) || !segmentPattern.test(repository) || !/^\d+$/.test(numberText)) {
1629
+ if (rawOwner === undefined || rawRepository === undefined || numberText === undefined || !segmentPattern.test(rawOwner) || !segmentPattern.test(rawRepository) || !/^\d+$/.test(numberText)) {
1623
1630
  return invalidPullRequestUrl;
1624
1631
  }
1625
1632
  const number = Number(numberText);
1626
1633
  if (!Number.isSafeInteger(number) || number <= 0)
1627
1634
  return invalidPullRequestUrl;
1635
+ const owner = rawOwner.toLowerCase();
1636
+ const repository = rawRepository.toLowerCase();
1628
1637
  const url = `https://github.com/${owner}/${repository}/pull/${number}`;
1629
1638
  const value = { url, owner, repository, number };
1630
1639
  return { ok: true, value };
@@ -1641,6 +1650,13 @@ var invalidGitHubResponse = {
1641
1650
  message: "GitHub returned an invalid pull request response"
1642
1651
  }
1643
1652
  };
1653
+ var pullRequestNotFound = {
1654
+ ok: false,
1655
+ error: {
1656
+ tag: "PullRequestNotFound",
1657
+ message: "Pull request does not exist or is not accessible"
1658
+ }
1659
+ };
1644
1660
  var githubBatchLimitExceeded = {
1645
1661
  ok: false,
1646
1662
  error: {
@@ -1649,15 +1665,13 @@ var githubBatchLimitExceeded = {
1649
1665
  message: "GitHub batch cannot contain more than 20 pull requests"
1650
1666
  }
1651
1667
  };
1652
- var checkRunPending = new Set(["QUEUED", "IN_PROGRESS", "WAITING", "PENDING"]);
1653
- var checkRunPassed = new Set(["SUCCESS"]);
1654
- var checkRunFailed = new Set(["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"]);
1655
- var checkRunIgnored = new Set(["NEUTRAL", "SKIPPED"]);
1656
- var statusContextPending = new Set(["EXPECTED", "PENDING"]);
1657
- var statusContextPassed = new Set(["SUCCESS"]);
1658
- var statusContextFailed = new Set(["ERROR", "FAILURE"]);
1659
1668
  var maximumPullRequestsPerBatch = 20;
1660
- var pullRequestSelection = `__typename ... on PullRequest { title state url mergedAt mergeable statusCheckRollup { contexts(first: 1) { checkRunCount statusContextCount checkRunCountsByState { state count } statusContextCountsByState { state count } } } }`;
1669
+ var maximumCheckContextsPerPage = 100;
1670
+ var statusContextOnlyFields = ["context", "state", "createdAt"];
1671
+ var checkRunOnlyFields = ["name", "status", "conclusion", "checkSuite"];
1672
+ 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 }`;
1673
+ 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} } } }`;
1674
+ var continuationQuery = `query PullRequestContexts($url: URI!, $cursor: String!) { resource(url: $url) { __typename ... on PullRequest { url statusCheckRollup { contexts(first: ${maximumCheckContextsPerPage}, after: $cursor) { ${checkContextSelection} } } } } }`;
1661
1675
  function isRecord(value) {
1662
1676
  return value !== null && typeof value === "object" && !Array.isArray(value);
1663
1677
  }
@@ -1673,67 +1687,297 @@ function parseProcessExecutionFailed(value) {
1673
1687
  cause: value.cause
1674
1688
  };
1675
1689
  }
1676
- function classifyCountState(state, states) {
1677
- if (states.failed.has(state))
1678
- return "failed";
1679
- if (states.pending.has(state))
1680
- return "pending";
1681
- if (states.passed.has(state))
1682
- return "passed";
1683
- if (states.ignored?.has(state))
1684
- return "ignored";
1685
- return;
1690
+ function parseNonBlankString(input) {
1691
+ return typeof input === "string" && input.trim() !== "" ? input : undefined;
1692
+ }
1693
+ function parseDate(input) {
1694
+ if (typeof input !== "string")
1695
+ return;
1696
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:[Zz]|[+-](\d{2}):(\d{2}))$/.exec(input);
1697
+ if (match === null)
1698
+ return;
1699
+ const year = Number(match[1]);
1700
+ const month = Number(match[2]);
1701
+ const day = Number(match[3]);
1702
+ const hour = Number(match[4]);
1703
+ const minute = Number(match[5]);
1704
+ const second = Number(match[6]);
1705
+ const offsetHour = Number(match[8] ?? 0);
1706
+ const offsetMinute = Number(match[9] ?? 0);
1707
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
1708
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
1709
+ if (daysInMonth === undefined || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
1710
+ return;
1711
+ }
1712
+ if (Number.isNaN(new Date(input).valueOf()))
1713
+ return;
1714
+ const epochSeconds = new Date(input.replace(/\.\d+/, "")).valueOf() / 1000;
1715
+ if (!Number.isInteger(epochSeconds))
1716
+ return;
1717
+ return {
1718
+ epochSeconds,
1719
+ fractionalSeconds: (match[7] ?? "").replace(/0+$/, "")
1720
+ };
1721
+ }
1722
+ function compareTimestamps(left, right) {
1723
+ if (left.epochSeconds < right.epochSeconds)
1724
+ return -1;
1725
+ if (left.epochSeconds > right.epochSeconds)
1726
+ return 1;
1727
+ const width = Math.max(left.fractionalSeconds.length, right.fractionalSeconds.length);
1728
+ const leftFraction = left.fractionalSeconds.padEnd(width, "0");
1729
+ const rightFraction = right.fractionalSeconds.padEnd(width, "0");
1730
+ if (leftFraction < rightFraction)
1731
+ return -1;
1732
+ if (leftFraction > rightFraction)
1733
+ return 1;
1734
+ return 0;
1735
+ }
1736
+ function parseStatusContextState(input) {
1737
+ switch (input) {
1738
+ case "EXPECTED":
1739
+ case "PENDING":
1740
+ case "SUCCESS":
1741
+ case "ERROR":
1742
+ case "FAILURE":
1743
+ return input;
1744
+ default:
1745
+ return;
1746
+ }
1747
+ }
1748
+ function parseCheckRunStatus(input) {
1749
+ switch (input) {
1750
+ case "REQUESTED":
1751
+ case "QUEUED":
1752
+ case "IN_PROGRESS":
1753
+ case "COMPLETED":
1754
+ case "WAITING":
1755
+ case "PENDING":
1756
+ return input;
1757
+ default:
1758
+ return;
1759
+ }
1760
+ }
1761
+ function parseCheckRunConclusion(input) {
1762
+ switch (input) {
1763
+ case null:
1764
+ case "SUCCESS":
1765
+ case "FAILURE":
1766
+ case "CANCELLED":
1767
+ case "TIMED_OUT":
1768
+ case "ACTION_REQUIRED":
1769
+ case "STARTUP_FAILURE":
1770
+ case "STALE":
1771
+ case "NEUTRAL":
1772
+ case "SKIPPED":
1773
+ return input;
1774
+ default:
1775
+ return;
1776
+ }
1686
1777
  }
1687
- function aggregateCounts(input, expectedTotal, states) {
1688
- if (!Array.isArray(input) || !Number.isInteger(expectedTotal) || Number(expectedTotal) < 0) {
1778
+ function parseStatusContext(input) {
1779
+ const id = parseNonBlankString(input.id);
1780
+ const context = parseNonBlankString(input.context);
1781
+ const state = parseStatusContextState(input.state);
1782
+ const createdAt = parseDate(input.createdAt);
1783
+ if (id === undefined || context === undefined || state === undefined || createdAt === undefined) {
1689
1784
  return invalidGitHubResponse;
1690
1785
  }
1691
- const buckets = new Set;
1692
- const seenStates = new Set;
1693
- let total = 0;
1694
- for (const item of input) {
1695
- if (!isRecord(item) || typeof item.state !== "string" || !Number.isInteger(item.count) || Number(item.count) < 0 || seenStates.has(item.state)) {
1786
+ return { ok: true, value: { tag: "StatusContext", id, context, state, createdAt } };
1787
+ }
1788
+ function parseCheckRun(input) {
1789
+ const id = parseNonBlankString(input.id);
1790
+ const name = parseNonBlankString(input.name);
1791
+ const status = parseCheckRunStatus(input.status);
1792
+ const conclusion = parseCheckRunConclusion(input.conclusion);
1793
+ if (id === undefined || name === undefined || status === undefined || conclusion === undefined || status !== "COMPLETED" && conclusion !== null || !isRecord(input.checkSuite)) {
1794
+ return invalidGitHubResponse;
1795
+ }
1796
+ const suiteId = parseNonBlankString(input.checkSuite.id);
1797
+ const suiteCreatedAt = parseDate(input.checkSuite.createdAt);
1798
+ if (suiteId === undefined || suiteCreatedAt === undefined)
1799
+ return invalidGitHubResponse;
1800
+ let sourceIdentity;
1801
+ if (input.checkSuite.app === null) {
1802
+ sourceIdentity = ["suite", suiteId];
1803
+ } else {
1804
+ if (!isRecord(input.checkSuite.app))
1805
+ return invalidGitHubResponse;
1806
+ const appId = parseNonBlankString(input.checkSuite.app.id);
1807
+ if (appId === undefined)
1808
+ return invalidGitHubResponse;
1809
+ sourceIdentity = ["app", appId];
1810
+ }
1811
+ let workflowRun;
1812
+ if (input.checkSuite.workflowRun === null) {
1813
+ workflowRun = undefined;
1814
+ } else {
1815
+ if (!isRecord(input.checkSuite.workflowRun) || !isRecord(input.checkSuite.workflowRun.workflow)) {
1696
1816
  return invalidGitHubResponse;
1697
1817
  }
1698
- const bucket = classifyCountState(item.state, states);
1699
- if (bucket === undefined && Number(item.count) > 0)
1818
+ const event = parseNonBlankString(input.checkSuite.workflowRun.event);
1819
+ const workflowId = parseNonBlankString(input.checkSuite.workflowRun.workflow.id);
1820
+ const runNumber = input.checkSuite.workflowRun.runNumber;
1821
+ const runAttempt = input.checkSuite.workflowRun.runAttempt;
1822
+ if (event === undefined || workflowId === undefined || !Number.isInteger(runNumber) || Number(runNumber) <= 0 || !Number.isInteger(runAttempt) || Number(runAttempt) <= 0) {
1700
1823
  return invalidGitHubResponse;
1701
- seenStates.add(item.state);
1702
- total += Number(item.count);
1703
- if (bucket !== undefined && Number(item.count) > 0)
1704
- buckets.add(bucket);
1824
+ }
1825
+ workflowRun = { event, workflowId, runNumber: Number(runNumber), runAttempt: Number(runAttempt) };
1705
1826
  }
1706
- return total === expectedTotal ? { ok: true, value: buckets } : invalidGitHubResponse;
1827
+ return {
1828
+ ok: true,
1829
+ value: { tag: "CheckRun", id, name, status, conclusion, suiteId, suiteCreatedAt, sourceIdentity, workflowRun }
1830
+ };
1707
1831
  }
1708
- function parseStatusCheckRollup(input) {
1709
- if (input === null)
1710
- return { ok: true, value: "none" };
1711
- if (!isRecord(input) || !isRecord(input.contexts))
1832
+ function parseCheckContexts(input) {
1833
+ 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") {
1712
1834
  return invalidGitHubResponse;
1713
- const contexts = input.contexts;
1714
- const checkRuns = aggregateCounts(contexts.checkRunCountsByState, contexts.checkRunCount, {
1715
- passed: checkRunPassed,
1716
- pending: checkRunPending,
1717
- failed: checkRunFailed,
1718
- ignored: checkRunIgnored
1719
- });
1720
- if (!checkRuns.ok)
1721
- return checkRuns;
1722
- const statusContexts = aggregateCounts(contexts.statusContextCountsByState, contexts.statusContextCount, {
1723
- passed: statusContextPassed,
1724
- pending: statusContextPending,
1725
- failed: statusContextFailed
1726
- });
1727
- if (!statusContexts.ok)
1728
- return statusContexts;
1729
- const buckets = new Set([...checkRuns.value, ...statusContexts.value]);
1835
+ }
1836
+ const nodes = input.nodes === null && input.totalCount === 0 ? [] : input.nodes;
1837
+ if (!Array.isArray(nodes) || nodes.length > maximumCheckContextsPerPage || nodes.length > Number(input.totalCount)) {
1838
+ return invalidGitHubResponse;
1839
+ }
1840
+ const contexts = [];
1841
+ const ids = new Set;
1842
+ for (const node of nodes) {
1843
+ if (!isRecord(node))
1844
+ return invalidGitHubResponse;
1845
+ let parsed;
1846
+ switch (node.__typename) {
1847
+ case "StatusContext":
1848
+ if (checkRunOnlyFields.some((field) => (field in node)))
1849
+ return invalidGitHubResponse;
1850
+ parsed = parseStatusContext(node);
1851
+ break;
1852
+ case "CheckRun":
1853
+ if (statusContextOnlyFields.some((field) => (field in node)))
1854
+ return invalidGitHubResponse;
1855
+ parsed = parseCheckRun(node);
1856
+ break;
1857
+ default:
1858
+ return invalidGitHubResponse;
1859
+ }
1860
+ if (!parsed.ok || ids.has(parsed.value.id))
1861
+ return invalidGitHubResponse;
1862
+ ids.add(parsed.value.id);
1863
+ contexts.push(parsed.value);
1864
+ }
1865
+ const nextCursor = input.pageInfo.hasNextPage ? parseNonBlankString(input.pageInfo.endCursor) : undefined;
1866
+ if (input.pageInfo.hasNextPage && nextCursor === undefined)
1867
+ return invalidGitHubResponse;
1868
+ if (nextCursor !== undefined && contexts.length === 0)
1869
+ return invalidGitHubResponse;
1870
+ return {
1871
+ ok: true,
1872
+ value: {
1873
+ contexts,
1874
+ totalCount: Number(input.totalCount),
1875
+ ...nextCursor === undefined ? {} : { nextCursor }
1876
+ }
1877
+ };
1878
+ }
1879
+ function classifyStatusContext(state) {
1880
+ switch (state) {
1881
+ case "ERROR":
1882
+ case "FAILURE":
1883
+ return "failed";
1884
+ case "EXPECTED":
1885
+ case "PENDING":
1886
+ return "pending";
1887
+ case "SUCCESS":
1888
+ return "passed";
1889
+ default:
1890
+ return casesHandled(state);
1891
+ }
1892
+ }
1893
+ function classifyCheckRun(checkRun) {
1894
+ if (checkRun.status !== "COMPLETED")
1895
+ return "pending";
1896
+ switch (checkRun.conclusion) {
1897
+ case "FAILURE":
1898
+ case "CANCELLED":
1899
+ case "TIMED_OUT":
1900
+ case "ACTION_REQUIRED":
1901
+ case "STARTUP_FAILURE":
1902
+ case "STALE":
1903
+ return "failed";
1904
+ case "SUCCESS":
1905
+ return "passed";
1906
+ case "NEUTRAL":
1907
+ case "SKIPPED":
1908
+ case null:
1909
+ return "ignored";
1910
+ default:
1911
+ return casesHandled(checkRun.conclusion);
1912
+ }
1913
+ }
1914
+ function classifyContexts(contexts) {
1915
+ const statusContexts = new Map;
1916
+ const workflowChecks = new Map;
1917
+ const nonWorkflowChecks = new Map;
1918
+ for (const context of contexts) {
1919
+ if (context.tag === "StatusContext") {
1920
+ const identity2 = context.context.toLowerCase();
1921
+ const existing2 = statusContexts.get(identity2);
1922
+ const bucket2 = classifyStatusContext(context.state);
1923
+ const ordering2 = existing2 === undefined ? 1 : compareTimestamps(context.createdAt, existing2.createdAt);
1924
+ if (ordering2 > 0) {
1925
+ statusContexts.set(identity2, { createdAt: context.createdAt, buckets: [bucket2] });
1926
+ } else if (ordering2 === 0 && existing2 !== undefined) {
1927
+ existing2.buckets.push(bucket2);
1928
+ }
1929
+ continue;
1930
+ }
1931
+ const bucket = classifyCheckRun(context);
1932
+ if (context.workflowRun !== undefined) {
1933
+ const identity2 = JSON.stringify([
1934
+ "workflow",
1935
+ context.sourceIdentity,
1936
+ context.workflowRun.workflowId,
1937
+ context.workflowRun.event,
1938
+ context.name
1939
+ ]);
1940
+ const existing2 = workflowChecks.get(identity2);
1941
+ const isNewer = existing2 === undefined || context.workflowRun.runNumber > existing2.runNumber || context.workflowRun.runNumber === existing2.runNumber && context.workflowRun.runAttempt > existing2.runAttempt;
1942
+ if (isNewer) {
1943
+ workflowChecks.set(identity2, {
1944
+ runNumber: context.workflowRun.runNumber,
1945
+ runAttempt: context.workflowRun.runAttempt,
1946
+ buckets: [bucket]
1947
+ });
1948
+ } else if (context.workflowRun.runNumber === existing2.runNumber && context.workflowRun.runAttempt === existing2.runAttempt) {
1949
+ existing2.buckets.push(bucket);
1950
+ }
1951
+ continue;
1952
+ }
1953
+ const identity = JSON.stringify(["check", context.sourceIdentity, context.name]);
1954
+ const existing = nonWorkflowChecks.get(identity);
1955
+ const ordering = existing === undefined ? 1 : compareTimestamps(context.suiteCreatedAt, existing.suiteCreatedAt);
1956
+ if (ordering > 0) {
1957
+ nonWorkflowChecks.set(identity, { suiteCreatedAt: context.suiteCreatedAt, buckets: [bucket] });
1958
+ } else if (ordering === 0 && existing !== undefined) {
1959
+ existing.buckets.push(bucket);
1960
+ }
1961
+ }
1962
+ const buckets = new Set;
1963
+ for (const selection of [...statusContexts.values(), ...workflowChecks.values(), ...nonWorkflowChecks.values()]) {
1964
+ for (const bucket of selection.buckets)
1965
+ buckets.add(bucket);
1966
+ }
1730
1967
  if (buckets.has("failed"))
1731
- return { ok: true, value: "failed" };
1968
+ return "failed";
1732
1969
  if (buckets.has("pending"))
1733
- return { ok: true, value: "pending" };
1970
+ return "pending";
1734
1971
  if (buckets.has("passed"))
1735
- return { ok: true, value: "passed" };
1736
- return { ok: true, value: "none" };
1972
+ return "passed";
1973
+ return "none";
1974
+ }
1975
+ function parseStatusCheckRollup(input) {
1976
+ if (input === null)
1977
+ return { ok: true, value: null };
1978
+ if (!isRecord(input))
1979
+ return invalidGitHubResponse;
1980
+ return parseCheckContexts(input.contexts);
1737
1981
  }
1738
1982
  function samePullRequest(left, right) {
1739
1983
  return left.number === right.number && left.owner.toLowerCase() === right.owner.toLowerCase() && left.repository.toLowerCase() === right.repository.toLowerCase();
@@ -1750,7 +1994,118 @@ function parseMergeability(input) {
1750
1994
  return invalidGitHubResponse;
1751
1995
  }
1752
1996
  }
1753
- function parseResponse(input, pullRequest) {
1997
+ function parseMergeStateStatus(input) {
1998
+ switch (input) {
1999
+ case "BEHIND":
2000
+ return { ok: true, value: "behind" };
2001
+ case "BLOCKED":
2002
+ case "CLEAN":
2003
+ case "DIRTY":
2004
+ case "DRAFT":
2005
+ case "HAS_HOOKS":
2006
+ case "UNKNOWN":
2007
+ case "UNSTABLE":
2008
+ return { ok: true, value: "other" };
2009
+ default:
2010
+ return invalidGitHubResponse;
2011
+ }
2012
+ }
2013
+ function parseRequiredStatusChecks(input) {
2014
+ if (!Array.isArray(input))
2015
+ return invalidGitHubResponse;
2016
+ for (const check of input) {
2017
+ if (!isRecord(check) || typeof check.context !== "string" || check.context.trim() === "") {
2018
+ return invalidGitHubResponse;
2019
+ }
2020
+ }
2021
+ return { ok: true, value: input.length > 0 };
2022
+ }
2023
+ function parseRules(input) {
2024
+ if (!isRecord(input) || !Number.isInteger(input.totalCount) || Number(input.totalCount) < 0 || !isRecord(input.pageInfo) || typeof input.pageInfo.hasNextPage !== "boolean") {
2025
+ return invalidGitHubResponse;
2026
+ }
2027
+ const nodes = input.nodes === null && input.totalCount === 0 ? [] : input.nodes;
2028
+ if (!Array.isArray(nodes) || nodes.length > Number(input.totalCount))
2029
+ return invalidGitHubResponse;
2030
+ if (input.pageInfo.hasNextPage ? nodes.length >= Number(input.totalCount) : nodes.length !== input.totalCount) {
2031
+ return invalidGitHubResponse;
2032
+ }
2033
+ let strict = false;
2034
+ for (const node of nodes) {
2035
+ if (!isRecord(node) || !(node.parameters === null || isRecord(node.parameters))) {
2036
+ return invalidGitHubResponse;
2037
+ }
2038
+ if (node.parameters === null)
2039
+ continue;
2040
+ if (typeof node.parameters.__typename !== "string")
2041
+ return invalidGitHubResponse;
2042
+ if (node.parameters.__typename !== "RequiredStatusChecksParameters")
2043
+ continue;
2044
+ if (typeof node.parameters.strictRequiredStatusChecksPolicy !== "boolean")
2045
+ return invalidGitHubResponse;
2046
+ const requiredChecks = parseRequiredStatusChecks(node.parameters.requiredStatusChecks);
2047
+ if (!requiredChecks.ok)
2048
+ return requiredChecks;
2049
+ if (node.parameters.strictRequiredStatusChecksPolicy && requiredChecks.value)
2050
+ strict = true;
2051
+ }
2052
+ return { ok: true, value: { strict, incomplete: input.pageInfo.hasNextPage } };
2053
+ }
2054
+ function parseRefUpdateRule(input) {
2055
+ if (input === null)
2056
+ return { ok: true, value: false };
2057
+ if (!isRecord(input))
2058
+ return invalidGitHubResponse;
2059
+ if (input.requiredStatusCheckContexts === null)
2060
+ return { ok: true, value: false };
2061
+ if (!Array.isArray(input.requiredStatusCheckContexts))
2062
+ return invalidGitHubResponse;
2063
+ for (const context of input.requiredStatusCheckContexts) {
2064
+ if (typeof context !== "string" || context.trim() === "")
2065
+ return invalidGitHubResponse;
2066
+ }
2067
+ return { ok: true, value: input.requiredStatusCheckContexts.length > 0 };
2068
+ }
2069
+ function parseUpdatePolicy(input) {
2070
+ if (input === null)
2071
+ return { ok: true, value: { strict: false, incomplete: false } };
2072
+ if (!isRecord(input))
2073
+ return invalidGitHubResponse;
2074
+ const refUpdateHasRequiredChecks = parseRefUpdateRule(input.refUpdateRule);
2075
+ if (!refUpdateHasRequiredChecks.ok)
2076
+ return refUpdateHasRequiredChecks;
2077
+ let branchProtectionIsStrict = false;
2078
+ if (input.branchProtectionRule !== null) {
2079
+ if (!isRecord(input.branchProtectionRule) || typeof input.branchProtectionRule.requiresStatusChecks !== "boolean" || typeof input.branchProtectionRule.requiresStrictStatusChecks !== "boolean") {
2080
+ return invalidGitHubResponse;
2081
+ }
2082
+ branchProtectionIsStrict = input.branchProtectionRule.requiresStatusChecks && input.branchProtectionRule.requiresStrictStatusChecks;
2083
+ }
2084
+ const rules = parseRules(input.rules);
2085
+ if (!rules.ok)
2086
+ return rules;
2087
+ return {
2088
+ ok: true,
2089
+ value: {
2090
+ strict: branchProtectionIsStrict || rules.value.strict,
2091
+ incomplete: rules.value.incomplete || input.branchProtectionRule === null && refUpdateHasRequiredChecks.value
2092
+ }
2093
+ };
2094
+ }
2095
+ function parseBlocker(mergeStateStatusInput, baseRefInput) {
2096
+ const mergeStateStatus = parseMergeStateStatus(mergeStateStatusInput);
2097
+ if (!mergeStateStatus.ok)
2098
+ return mergeStateStatus;
2099
+ if (mergeStateStatus.value !== "behind")
2100
+ return { ok: true, value: "none" };
2101
+ const updatePolicy = parseUpdatePolicy(baseRefInput);
2102
+ if (!updatePolicy.ok)
2103
+ return updatePolicy;
2104
+ if (updatePolicy.value.strict)
2105
+ return { ok: true, value: "behind" };
2106
+ return updatePolicy.value.incomplete ? invalidGitHubResponse : { ok: true, value: "none" };
2107
+ }
2108
+ function parsePullRequestMetadata(input, pullRequest) {
1754
2109
  if (!isRecord(input) || input.__typename !== "PullRequest" || typeof input.title !== "string" || input.title.trim() === "") {
1755
2110
  return invalidGitHubResponse;
1756
2111
  }
@@ -1771,17 +2126,34 @@ function parseResponse(input, pullRequest) {
1771
2126
  const responseUrl = parsePullRequestUrl(input.url);
1772
2127
  if (!responseUrl.ok || !samePullRequest(responseUrl.value, pullRequest))
1773
2128
  return invalidGitHubResponse;
1774
- const ci = parseStatusCheckRollup(input.statusCheckRollup);
1775
- if (!ci.ok)
1776
- return ci;
1777
2129
  const mergeability = parseMergeability(input.mergeable);
1778
2130
  if (!mergeability.ok)
1779
2131
  return mergeability;
2132
+ return {
2133
+ ok: true,
2134
+ value: {
2135
+ title: input.title,
2136
+ state: input.state,
2137
+ mergeability: mergeability.value,
2138
+ mergeStateStatus: input.mergeStateStatus,
2139
+ baseRef: input.baseRef
2140
+ }
2141
+ };
2142
+ }
2143
+ function finalizeResponse(metadata, pullRequest, ci) {
1780
2144
  let state;
1781
- switch (input.state) {
1782
- case "OPEN":
1783
- state = { tag: "Open", ci: ci.value, mergeability: mergeability.value };
2145
+ switch (metadata.state) {
2146
+ case "OPEN": {
2147
+ let blocker = "none";
2148
+ if (metadata.mergeability !== "conflicting" && (ci === "none" || ci === "passed")) {
2149
+ const parsedBlocker = parseBlocker(metadata.mergeStateStatus, metadata.baseRef);
2150
+ if (!parsedBlocker.ok)
2151
+ return parsedBlocker;
2152
+ blocker = parsedBlocker.value;
2153
+ }
2154
+ state = { tag: "Open", ci, mergeability: metadata.mergeability, blocker };
1784
2155
  break;
2156
+ }
1785
2157
  case "MERGED":
1786
2158
  state = { tag: "Merged" };
1787
2159
  break;
@@ -1789,19 +2161,33 @@ function parseResponse(input, pullRequest) {
1789
2161
  state = { tag: "Closed" };
1790
2162
  break;
1791
2163
  default:
1792
- return casesHandled(input.state);
2164
+ return casesHandled(metadata.state);
1793
2165
  }
1794
2166
  return {
1795
2167
  ok: true,
1796
2168
  value: {
1797
2169
  tag: "Available",
1798
2170
  pullRequest,
1799
- title: input.title,
2171
+ title: metadata.title,
1800
2172
  state,
1801
2173
  stale: false
1802
2174
  }
1803
2175
  };
1804
2176
  }
2177
+ function parseInitialPullRequest(input, pullRequest) {
2178
+ if (input === null)
2179
+ return pullRequestNotFound;
2180
+ if (!isRecord(input))
2181
+ return invalidGitHubResponse;
2182
+ const contextPage = parseStatusCheckRollup(input.statusCheckRollup);
2183
+ if (!contextPage.ok)
2184
+ return contextPage;
2185
+ if (contextPage.value !== null && contextPage.value.nextCursor === undefined && contextPage.value.contexts.length !== contextPage.value.totalCount) {
2186
+ return invalidGitHubResponse;
2187
+ }
2188
+ const metadata = parsePullRequestMetadata(input, pullRequest);
2189
+ return metadata.ok ? { ok: true, value: { pullRequest, metadata: metadata.value, contextPage: contextPage.value } } : metadata;
2190
+ }
1805
2191
  function createBatchQuery(size) {
1806
2192
  const variables = Array.from({ length: size }, (_, index) => `$url${index}: URI!`).join(", ");
1807
2193
  const fields = Array.from({ length: size }, (_, index) => `pr${index}: resource(url: $url${index}) { ${pullRequestSelection} }`).join(" ");
@@ -1836,9 +2222,22 @@ function parseBatchResponse(input, pullRequests) {
1836
2222
  return errorAliases;
1837
2223
  return {
1838
2224
  ok: true,
1839
- value: pullRequests.map((pullRequest, index) => errorAliases.value.has(index) ? invalidGitHubResponse : parseResponse(data[`pr${index}`], pullRequest))
2225
+ value: pullRequests.map((pullRequest, index) => errorAliases.value.has(index) ? invalidGitHubResponse : parseInitialPullRequest(data[`pr${index}`], pullRequest))
1840
2226
  };
1841
2227
  }
2228
+ function parseContinuationResponse(input, pullRequest) {
2229
+ if (!isRecord(input) || input.errors !== undefined && (!Array.isArray(input.errors) || input.errors.length > 0) || !isRecord(input.data) || !isRecord(input.data.resource)) {
2230
+ return invalidGitHubResponse;
2231
+ }
2232
+ const resource = input.data.resource;
2233
+ if (resource.__typename !== "PullRequest" || typeof resource.url !== "string")
2234
+ return invalidGitHubResponse;
2235
+ const responseUrl = parsePullRequestUrl(resource.url);
2236
+ if (!responseUrl.ok || !samePullRequest(responseUrl.value, pullRequest) || !isRecord(resource.statusCheckRollup)) {
2237
+ return invalidGitHubResponse;
2238
+ }
2239
+ return parseCheckContexts(resource.statusCheckRollup.contexts);
2240
+ }
1842
2241
  function isCancellation(cause, signal) {
1843
2242
  if (signal?.aborted)
1844
2243
  return true;
@@ -1867,6 +2266,94 @@ function processFailureStdout(cause) {
1867
2266
  return;
1868
2267
  return cause.stdout;
1869
2268
  }
2269
+ async function runAndDecode(runner, args, options) {
2270
+ let stdout;
2271
+ let processFailure;
2272
+ try {
2273
+ const output = await runner("gh", args, options);
2274
+ stdout = output.stdout;
2275
+ } catch (cause) {
2276
+ if (isCancellation(cause, options.signal)) {
2277
+ return {
2278
+ ok: false,
2279
+ error: {
2280
+ tag: "GitHubCancelled",
2281
+ message: "GitHub status request cancelled",
2282
+ cause
2283
+ }
2284
+ };
2285
+ }
2286
+ processFailure = classifyProcessFailure(cause);
2287
+ const partialStdout = processFailureStdout(cause);
2288
+ if (partialStdout === undefined)
2289
+ return { ok: false, error: processFailure };
2290
+ stdout = partialStdout;
2291
+ }
2292
+ let decoded;
2293
+ try {
2294
+ decoded = JSON.parse(stdout);
2295
+ } catch {
2296
+ return processFailure === undefined ? invalidGitHubResponse : { ok: false, error: processFailure };
2297
+ }
2298
+ return { ok: true, value: { decoded, ...processFailure === undefined ? {} : { processFailure } } };
2299
+ }
2300
+ async function continuePullRequest(runner, initial, options) {
2301
+ if (initial.contextPage?.nextCursor === undefined) {
2302
+ const ci = initial.contextPage === null ? "none" : classifyContexts(initial.contextPage.contexts);
2303
+ return { tag: "Item", result: finalizeResponse(initial.metadata, initial.pullRequest, ci) };
2304
+ }
2305
+ const contexts = [...initial.contextPage.contexts];
2306
+ const totalCount = initial.contextPage.totalCount;
2307
+ const contextIds = new Set(contexts.map((context) => context.id));
2308
+ const cursors = new Set([initial.contextPage.nextCursor]);
2309
+ let cursor = initial.contextPage.nextCursor;
2310
+ while (cursor !== undefined) {
2311
+ const args = [
2312
+ "api",
2313
+ "graphql",
2314
+ "--method",
2315
+ "POST",
2316
+ "-f",
2317
+ `query=${continuationQuery}`,
2318
+ "-f",
2319
+ `url=${initial.pullRequest.url}`,
2320
+ "-f",
2321
+ `cursor=${cursor}`
2322
+ ];
2323
+ const output = await runAndDecode(runner, args, options);
2324
+ if (!output.ok) {
2325
+ return output.error.tag === "GitHubCancelled" ? { tag: "Cancelled", error: output.error } : { tag: "Item", result: { ok: false, error: output.error } };
2326
+ }
2327
+ const page = parseContinuationResponse(output.value.decoded, initial.pullRequest);
2328
+ if (!page.ok) {
2329
+ return {
2330
+ tag: "Item",
2331
+ result: { ok: false, error: output.value.processFailure ?? page.error }
2332
+ };
2333
+ }
2334
+ if (page.value.totalCount !== totalCount)
2335
+ return { tag: "Item", result: invalidGitHubResponse };
2336
+ for (const context of page.value.contexts) {
2337
+ if (contextIds.has(context.id))
2338
+ return { tag: "Item", result: invalidGitHubResponse };
2339
+ contextIds.add(context.id);
2340
+ }
2341
+ if (contexts.length + page.value.contexts.length > totalCount) {
2342
+ return { tag: "Item", result: invalidGitHubResponse };
2343
+ }
2344
+ if (page.value.nextCursor !== undefined) {
2345
+ if (cursors.has(page.value.nextCursor))
2346
+ return { tag: "Item", result: invalidGitHubResponse };
2347
+ cursors.add(page.value.nextCursor);
2348
+ }
2349
+ contexts.push(...page.value.contexts);
2350
+ cursor = page.value.nextCursor;
2351
+ }
2352
+ if (contexts.length !== totalCount)
2353
+ return { tag: "Item", result: invalidGitHubResponse };
2354
+ const status = finalizeResponse(initial.metadata, initial.pullRequest, classifyContexts(contexts));
2355
+ return { tag: "Item", result: status };
2356
+ }
1870
2357
  var execFileRunner = (file, args, options) => new Promise((resolve, reject) => {
1871
2358
  execFile(file, [...args], {
1872
2359
  encoding: "utf8",
@@ -1898,40 +2385,26 @@ function createGitHubClient(runner = execFileRunner) {
1898
2385
  for (const [index, pullRequest] of pullRequests.entries()) {
1899
2386
  args.push("-f", `url${index}=${pullRequest.url}`);
1900
2387
  }
1901
- let stdout;
1902
- let processFailure;
1903
- try {
1904
- const output = await runner("gh", args, options);
1905
- stdout = output.stdout;
1906
- } catch (cause) {
1907
- if (isCancellation(cause, options.signal)) {
1908
- return {
1909
- ok: false,
1910
- error: {
1911
- tag: "GitHubCancelled",
1912
- message: "GitHub status request cancelled",
1913
- cause
1914
- }
1915
- };
1916
- }
1917
- processFailure = classifyProcessFailure(cause);
1918
- const partialStdout = processFailureStdout(cause);
1919
- if (partialStdout !== undefined) {
1920
- stdout = partialStdout;
1921
- } else {
1922
- return { ok: false, error: processFailure };
1923
- }
1924
- }
1925
- let decoded;
1926
- try {
1927
- decoded = JSON.parse(stdout);
1928
- } catch {
1929
- return processFailure === undefined ? invalidGitHubResponse : { ok: false, error: processFailure };
2388
+ const output = await runAndDecode(runner, args, options);
2389
+ if (!output.ok)
2390
+ return output;
2391
+ const parsed = parseBatchResponse(output.value.decoded, pullRequests);
2392
+ if (!parsed.ok)
2393
+ return { ok: false, error: output.value.processFailure ?? parsed.error };
2394
+ const outcomes = await Promise.all(parsed.value.map((item) => {
2395
+ if (!item.ok)
2396
+ return Promise.resolve({ tag: "Item", result: item });
2397
+ return continuePullRequest(runner, item.value, options);
2398
+ }));
2399
+ const batch = [];
2400
+ let cancellation;
2401
+ for (const outcome of outcomes) {
2402
+ if (outcome.tag === "Cancelled")
2403
+ cancellation ??= outcome.error;
2404
+ else
2405
+ batch.push(outcome.result);
1930
2406
  }
1931
- const parsed = parseBatchResponse(decoded, pullRequests);
1932
- if (processFailure === undefined || parsed.ok)
1933
- return parsed;
1934
- return { ok: false, error: processFailure };
2407
+ return cancellation === undefined ? { ok: true, value: batch } : { ok: false, error: cancellation };
1935
2408
  }
1936
2409
  };
1937
2410
  }
@@ -1945,6 +2418,7 @@ var diagnosticLabels = {
1945
2418
  GitHubCliMissing: "install gh",
1946
2419
  GitHubAuthenticationRequired: "run gh auth login",
1947
2420
  GitHubUnavailable: "GitHub unavailable",
2421
+ PullRequestNotFound: "not found or inaccessible",
1948
2422
  InvalidGitHubResponse: "invalid GitHub response"
1949
2423
  };
1950
2424
  function stateAppearance(state) {
@@ -1955,7 +2429,23 @@ function stateAppearance(state) {
1955
2429
  return { tone: "red", label: "merge conflict", strikethrough: false };
1956
2430
  case "mergeable":
1957
2431
  case "unknown":
1958
- return openAppearances[state.ci];
2432
+ switch (state.ci) {
2433
+ case "failed":
2434
+ case "pending":
2435
+ return openAppearances[state.ci];
2436
+ case "none":
2437
+ case "passed":
2438
+ switch (state.blocker) {
2439
+ case "behind":
2440
+ return { tone: "yellow", label: "branch behind", strikethrough: false };
2441
+ case "none":
2442
+ return openAppearances[state.ci];
2443
+ default:
2444
+ return casesHandled(state.blocker);
2445
+ }
2446
+ default:
2447
+ return casesHandled(state.ci);
2448
+ }
1959
2449
  default:
1960
2450
  return casesHandled(state.mergeability);
1961
2451
  }
@@ -1980,866 +2470,2005 @@ function statusAppearance(status) {
1980
2470
  return status.stale ? { ...appearance, label: `${appearance.label} (stale; ${diagnosticLabels[status.diagnostic]})` } : appearance;
1981
2471
  }
1982
2472
 
1983
- // src/attach.ts
1984
- var invalidPullRequestInput = {
1985
- ok: false,
1986
- error: {
1987
- tag: "InvalidPullRequestInput",
1988
- message: "Expected https://github.com/<owner>/<repository>/pull/<positive-integer> or a positive pull request number"
2473
+ // src/external-url.ts
2474
+ async function openExternalUrl(url, subject, options = {}) {
2475
+ const platform = options.platform ?? process.platform;
2476
+ const executable = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : undefined;
2477
+ if (executable === undefined) {
2478
+ return {
2479
+ ok: false,
2480
+ error: {
2481
+ tag: "UnsupportedPlatform",
2482
+ message: `Opening ${subject} is unsupported on ${platform}`,
2483
+ platform
2484
+ }
2485
+ };
1989
2486
  }
1990
- };
1991
- var repositoryResolutionFailed = {
1992
- tag: "RepositoryResolutionFailed",
1993
- message: "Unable to resolve the current GitHub repository with gh; attach with a full URL instead"
1994
- };
1995
- var repositoryResolutionCancelled = {
1996
- ok: false,
1997
- error: { tag: "RepositoryResolutionCancelled" }
1998
- };
1999
- function isCancellation2(cause, signal) {
2000
- if (signal?.aborted)
2001
- return true;
2002
- return cause instanceof Error && cause.name === "AbortError";
2003
- }
2004
- function parseRepositoryPullRequest(stdout, number) {
2005
- let decoded;
2006
2487
  try {
2007
- decoded = JSON.parse(stdout);
2008
- } catch {
2009
- return { ok: false, error: repositoryResolutionFailed };
2010
- }
2011
- if (decoded === null || typeof decoded !== "object" || !("url" in decoded) || typeof decoded.url !== "string" || decoded.url === "") {
2012
- return { ok: false, error: repositoryResolutionFailed };
2488
+ await (options.runner ?? execFileRunner)(executable, [url], options.signal ? { signal: options.signal } : {});
2489
+ return { ok: true, value: undefined };
2490
+ } catch (cause) {
2491
+ return {
2492
+ ok: false,
2493
+ error: { tag: "OpenExternalUrlFailed", message: `Unable to open ${subject}`, cause }
2494
+ };
2013
2495
  }
2014
- const repositoryUrl = decoded.url.endsWith("/") ? decoded.url.slice(0, -1) : decoded.url;
2015
- const pullRequest = parsePullRequestUrl(`${repositoryUrl}/pull/${number}`);
2016
- return pullRequest.ok ? pullRequest : { ok: false, error: repositoryResolutionFailed };
2017
2496
  }
2018
- async function resolvePullRequestInput(input, options) {
2019
- const direct = parsePullRequestUrl(input);
2020
- if (direct.ok)
2021
- return direct;
2022
- if (input.trim() !== input || !/^\d+$/.test(input))
2023
- return invalidPullRequestInput;
2024
- const number = Number(input);
2025
- if (!Number.isSafeInteger(number) || number <= 0)
2026
- return invalidPullRequestInput;
2027
- let stdout;
2028
- try {
2029
- const result = await (options.runner ?? execFileRunner)("gh", ["repo", "view", "--json", "url"], {
2030
- cwd: options.directory,
2031
- ...options.signal ? { signal: options.signal } : {}
2032
- });
2033
- stdout = result.stdout;
2034
- } catch (cause) {
2035
- if (isCancellation2(cause, options.signal))
2036
- return repositoryResolutionCancelled;
2037
- return { ok: false, error: { ...repositoryResolutionFailed, cause } };
2497
+ async function openPullRequest(pullRequest, options = {}) {
2498
+ const result = await openExternalUrl(pullRequest.url, "pull requests", options);
2499
+ if (result.ok)
2500
+ return result;
2501
+ if (result.error.tag === "UnsupportedPlatform") {
2502
+ return {
2503
+ ok: false,
2504
+ error: result.error
2505
+ };
2038
2506
  }
2039
- return parseRepositoryPullRequest(stdout, number);
2507
+ return {
2508
+ ok: false,
2509
+ error: {
2510
+ tag: "OpenPullRequestFailed",
2511
+ message: "Unable to open the pull request",
2512
+ cause: result.error.cause
2513
+ }
2514
+ };
2040
2515
  }
2041
2516
 
2042
- // src/state.ts
2043
- var import_proper_lockfile = __toESM(require_proper_lockfile(), 1);
2044
- import { createHash, randomUUID } from "crypto";
2045
- import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
2046
- import { homedir } from "os";
2047
- import { join } from "path";
2048
- var maximumPullRequestsPerSession = 20;
2049
- var invalidStateFile = {
2050
- ok: false,
2051
- error: {
2052
- tag: "InvalidStateFile",
2053
- message: "The session pull request state file is invalid"
2517
+ // src/feedback.ts
2518
+ function parseProcessExecutionFailed2(value) {
2519
+ if (value === null || typeof value !== "object" || Array.isArray(value) || !("tag" in value) || value.tag !== "ProcessExecutionFailed" || !("code" in value) || value.code !== null && typeof value.code !== "string" && typeof value.code !== "number" || !("stderr" in value) || typeof value.stderr !== "string" || !("stdout" in value) || typeof value.stdout !== "string" || !("cause" in value)) {
2520
+ return;
2054
2521
  }
2055
- };
2056
- var lockStaleMilliseconds = 1e4;
2057
- var lockUpdateMilliseconds = 2000;
2058
- function stateUnavailable(operation, message, cause) {
2059
- return { tag: "StateUnavailable", operation, message, cause };
2522
+ return {
2523
+ tag: "ProcessExecutionFailed",
2524
+ code: value.code,
2525
+ stderr: value.stderr,
2526
+ stdout: value.stdout,
2527
+ cause: value.cause
2528
+ };
2060
2529
  }
2061
- function isRecord2(value) {
2062
- return value !== null && typeof value === "object" && !Array.isArray(value);
2530
+ function isProcessCancellation(cause, signal) {
2531
+ if (signal?.aborted)
2532
+ return true;
2533
+ const originalCause = parseProcessExecutionFailed2(cause)?.cause ?? cause;
2534
+ return originalCause instanceof Error && originalCause.name === "AbortError";
2063
2535
  }
2064
- function hasExactKeys(value, keys) {
2065
- const actual = Object.keys(value);
2066
- return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
2536
+ var authenticationFailureMarkers2 = ["http 401", "bad credentials", "not logged into", "gh auth login"];
2537
+ function isAuthenticationFailure2(failure) {
2538
+ if (failure.code === 4)
2539
+ return true;
2540
+ const stderr = failure.stderr.toLowerCase();
2541
+ return authenticationFailureMarkers2.some((marker) => stderr.includes(marker));
2067
2542
  }
2068
- function parseState(input) {
2069
- if (!isRecord2(input) || !hasExactKeys(input, ["version", "pullRequests"]))
2070
- return invalidStateFile;
2071
- if (input.version !== 1 || !Array.isArray(input.pullRequests))
2072
- return invalidStateFile;
2073
- if (input.pullRequests.length > maximumPullRequestsPerSession)
2074
- return invalidStateFile;
2075
- const attachments = [];
2076
- const seen = new Set;
2077
- for (const item of input.pullRequests) {
2078
- if (!isRecord2(item) || !hasExactKeys(item, ["url", "attachedAt"]))
2079
- return invalidStateFile;
2080
- if (typeof item.url !== "string" || typeof item.attachedAt !== "string")
2081
- return invalidStateFile;
2082
- const parsed = parsePullRequestUrl(item.url);
2083
- if (!parsed.ok || parsed.value.url !== item.url || seen.has(item.url))
2084
- return invalidStateFile;
2085
- const attachedAt = new Date(item.attachedAt);
2086
- if (Number.isNaN(attachedAt.valueOf()) || attachedAt.toISOString() !== item.attachedAt)
2087
- return invalidStateFile;
2088
- seen.add(item.url);
2089
- attachments.push({ pullRequest: parsed.value, attachedAt: item.attachedAt });
2543
+ function trimRequired(value, field) {
2544
+ const trimmed = value.trim();
2545
+ if (trimmed !== "")
2546
+ return { ok: true, value: trimmed };
2547
+ return {
2548
+ ok: false,
2549
+ error: { tag: "InvalidFeedback", message: `${field} must not be empty` }
2550
+ };
2551
+ }
2552
+ function appendDiagnostics(draft, diagnostics) {
2553
+ if (diagnostics === undefined)
2554
+ return { ok: true, value: draft };
2555
+ const entries = [
2556
+ ["pluginVersion", "Plugin version", diagnostics.pluginVersion],
2557
+ ["opencodeVersion", "OpenCode version", diagnostics.opencodeVersion],
2558
+ ["operatingSystem", "Operating system", diagnostics.operatingSystem],
2559
+ ["installationSource", "Installation source", diagnostics.installationSource]
2560
+ ];
2561
+ const lines = [];
2562
+ for (const [field, label, value] of entries) {
2563
+ const trimmed = trimRequired(value, field);
2564
+ if (!trimmed.ok)
2565
+ return trimmed;
2566
+ lines.push(`- ${label}: ${trimmed.value}`);
2090
2567
  }
2091
- return { ok: true, value: attachments };
2568
+ return {
2569
+ ok: true,
2570
+ value: {
2571
+ ...draft,
2572
+ body: [draft.body, "", "## Diagnostics", "", ...lines].join(`
2573
+ `)
2574
+ }
2575
+ };
2092
2576
  }
2093
- function isMissingFile(cause) {
2094
- return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
2577
+ function createFeedbackDraft(input, diagnostics) {
2578
+ const title = trimRequired(input.title, "title");
2579
+ if (!title.ok)
2580
+ return title;
2581
+ switch (input.kind) {
2582
+ case "bug": {
2583
+ const problem = trimRequired(input.problem, "problem");
2584
+ if (!problem.ok)
2585
+ return problem;
2586
+ const reproduction = trimRequired(input.reproduction, "reproduction");
2587
+ if (!reproduction.ok)
2588
+ return reproduction;
2589
+ const expectedBehavior = trimRequired(input.expectedBehavior, "expectedBehavior");
2590
+ if (!expectedBehavior.ok)
2591
+ return expectedBehavior;
2592
+ return appendDiagnostics({
2593
+ title: title.value,
2594
+ body: [
2595
+ "## Problem",
2596
+ "",
2597
+ problem.value,
2598
+ "",
2599
+ "## Reproduction",
2600
+ "",
2601
+ reproduction.value,
2602
+ "",
2603
+ "## Expected Behavior",
2604
+ "",
2605
+ expectedBehavior.value
2606
+ ].join(`
2607
+ `),
2608
+ label: "bug",
2609
+ template: "bug_report.md"
2610
+ }, diagnostics);
2611
+ }
2612
+ case "feature": {
2613
+ const problem = trimRequired(input.problem, "problem");
2614
+ if (!problem.ok)
2615
+ return problem;
2616
+ const desiredOutcome = trimRequired(input.desiredOutcome, "desiredOutcome");
2617
+ if (!desiredOutcome.ok)
2618
+ return desiredOutcome;
2619
+ const body = ["## Problem", "", problem.value, "", "## Desired Outcome", "", desiredOutcome.value];
2620
+ const constraints = input.constraints?.trim();
2621
+ if (constraints !== undefined && constraints !== "") {
2622
+ body.push("", "## Constraints", "", constraints);
2623
+ }
2624
+ return appendDiagnostics({
2625
+ title: title.value,
2626
+ body: body.join(`
2627
+ `),
2628
+ label: "enhancement",
2629
+ template: "feature_request.md"
2630
+ }, diagnostics);
2631
+ }
2632
+ case "other": {
2633
+ const details = trimRequired(input.details, "details");
2634
+ if (!details.ok)
2635
+ return details;
2636
+ return appendDiagnostics({
2637
+ title: title.value,
2638
+ body: ["## Details", "", details.value].join(`
2639
+ `)
2640
+ }, diagnostics);
2641
+ }
2642
+ default:
2643
+ return casesHandled(input);
2644
+ }
2095
2645
  }
2096
- function fileName(sessionID) {
2097
- return `${createHash("sha256").update(sessionID).digest("hex")}.json`;
2646
+ function createFeedbackIssueUrl(draft) {
2647
+ const url = new URL("https://github.com/hcrosse/opencode-pr-tracker/issues/new");
2648
+ url.searchParams.set("title", draft.title);
2649
+ url.searchParams.set("body", draft.body);
2650
+ if (draft.template !== undefined)
2651
+ url.searchParams.set("template", draft.template);
2652
+ return url.toString();
2098
2653
  }
2099
- function defaultStateDirectory(environment = process.env, home = homedir()) {
2100
- const dataHome = environment.XDG_DATA_HOME || join(home, ".local", "share");
2101
- return join(dataHome, "opencode", "opencode-pr-tracker");
2654
+ async function openFeedbackDraft(draft, options = {}) {
2655
+ const url = createFeedbackIssueUrl(draft);
2656
+ if (url.length > 8000) {
2657
+ return {
2658
+ ok: false,
2659
+ error: {
2660
+ tag: "FeedbackUrlTooLong",
2661
+ message: "Feedback is too long for browser delivery; choose GitHub CLI delivery"
2662
+ }
2663
+ };
2664
+ }
2665
+ const result = await openExternalUrl(url, "feedback", options);
2666
+ if (result.ok)
2667
+ return result;
2668
+ if (result.error.tag === "UnsupportedPlatform") {
2669
+ return {
2670
+ ok: false,
2671
+ error: result.error
2672
+ };
2673
+ }
2674
+ return {
2675
+ ok: false,
2676
+ error: {
2677
+ tag: "OpenFeedbackFailed",
2678
+ message: "Unable to open feedback; choose GitHub CLI delivery or retry",
2679
+ cause: result.error.cause
2680
+ }
2681
+ };
2102
2682
  }
2103
- function createStateStore(options = {}) {
2104
- const directory = options.directory ?? defaultStateDirectory();
2105
- const now = options.now ?? (() => new Date);
2106
- async function acquireLock(sessionID) {
2107
- const stateFile = join(directory, fileName(sessionID));
2108
- let compromised;
2109
- try {
2110
- await mkdir(directory, { recursive: true });
2111
- const release = await import_proper_lockfile.lock(stateFile, {
2112
- realpath: false,
2113
- stale: lockStaleMilliseconds,
2114
- update: lockUpdateMilliseconds,
2115
- retries: { retries: 50, factor: 1, minTimeout: 10, maxTimeout: 100 },
2116
- onCompromised: (error) => {
2117
- compromised = error;
2683
+ async function submitFeedbackDraft(draft, options = {}) {
2684
+ const args = [
2685
+ "issue",
2686
+ "create",
2687
+ "--repo",
2688
+ "hcrosse/opencode-pr-tracker",
2689
+ "--title",
2690
+ draft.title,
2691
+ "--body",
2692
+ draft.body
2693
+ ];
2694
+ let stdout;
2695
+ try {
2696
+ const result = await (options.runner ?? execFileRunner)("gh", args, options.signal ? { signal: options.signal } : {});
2697
+ stdout = result.stdout;
2698
+ } catch (cause) {
2699
+ if (isProcessCancellation(cause, options.signal)) {
2700
+ return { ok: false, error: { tag: "SubmitFeedbackCancelled" } };
2701
+ }
2702
+ const failure = parseProcessExecutionFailed2(cause);
2703
+ if (failure?.code === "ENOENT") {
2704
+ return {
2705
+ ok: false,
2706
+ error: {
2707
+ tag: "GitHubCliMissing",
2708
+ message: "GitHub CLI is not installed; install gh and retry",
2709
+ cause
2118
2710
  }
2119
- });
2120
- return { ok: true, value: { release, compromised: () => compromised } };
2121
- } catch (cause) {
2711
+ };
2712
+ }
2713
+ if (failure !== undefined && isAuthenticationFailure2(failure)) {
2122
2714
  return {
2123
2715
  ok: false,
2124
- error: stateUnavailable("write", "Unable to lock the session pull request state", cause)
2716
+ error: {
2717
+ tag: "GitHubAuthenticationRequired",
2718
+ message: "GitHub CLI authentication required; run gh auth login",
2719
+ cause
2720
+ }
2125
2721
  };
2126
2722
  }
2723
+ return {
2724
+ ok: false,
2725
+ error: {
2726
+ tag: "SubmitFeedbackFailed",
2727
+ message: "Unable to submit feedback with GitHub CLI; choose browser delivery or retry",
2728
+ cause
2729
+ }
2730
+ };
2127
2731
  }
2128
- async function withLock(sessionID, operation) {
2129
- const lock = await acquireLock(sessionID);
2130
- if (!lock.ok)
2131
- return lock;
2132
- let result;
2133
- try {
2134
- result = await operation();
2135
- } catch (cause) {
2136
- await lock.value.release().catch(() => {
2732
+ const issueUrl = stdout.trim();
2733
+ if (issueUrl === "") {
2734
+ return {
2735
+ ok: false,
2736
+ error: {
2737
+ tag: "InvalidGitHubResponse",
2738
+ message: "GitHub CLI did not return the created issue URL"
2739
+ }
2740
+ };
2741
+ }
2742
+ return { ok: true, value: issueUrl };
2743
+ }
2744
+
2745
+ // src/feedback-tui.tsx
2746
+ function FeedbackConfirmation(props) {
2747
+ useKeyboard((key) => {
2748
+ if (key.name === "return") {
2749
+ key.preventDefault();
2750
+ key.stopPropagation();
2751
+ props.onConfirm();
2752
+ return;
2753
+ }
2754
+ if (key.name === "escape" || key.ctrl && key.name === "c") {
2755
+ key.preventDefault();
2756
+ key.stopPropagation();
2757
+ props.onCancel();
2758
+ }
2759
+ });
2760
+ return (() => {
2761
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b"), _el$4 = _$createElement("scrollbox"), _el$5 = _$createElement("text"), _el$6 = _$createElement("box"), _el$7 = _$createElement("text"), _el$9 = _$createElement("text");
2762
+ _$insertNode(_el$, _el$2);
2763
+ _$insertNode(_el$, _el$4);
2764
+ _$insertNode(_el$, _el$6);
2765
+ _$setProp(_el$, "flexDirection", "column");
2766
+ _$setProp(_el$, "gap", 1);
2767
+ _$insertNode(_el$2, _el$3);
2768
+ _$insert(_el$3, () => props.title);
2769
+ _$insertNode(_el$4, _el$5);
2770
+ _$setProp(_el$4, "focused", true);
2771
+ _$setProp(_el$4, "scrollY", true);
2772
+ _$setProp(_el$4, "height", 10);
2773
+ _$insert(_el$5, () => props.preview);
2774
+ _$insertNode(_el$6, _el$7);
2775
+ _$insertNode(_el$6, _el$9);
2776
+ _$setProp(_el$6, "flexDirection", "row");
2777
+ _$setProp(_el$6, "gap", 2);
2778
+ _$insertNode(_el$7, _$createTextNode(`[Esc] Cancel`));
2779
+ _$insert(_el$9, () => props.confirmLabel);
2780
+ _$effect((_p$) => {
2781
+ var { onCancel: _v$, onConfirm: _v$2 } = props;
2782
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$7, "onMouseUp", _v$, _p$.e));
2783
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$9, "onMouseUp", _v$2, _p$.t));
2784
+ return _p$;
2785
+ }, {
2786
+ e: undefined,
2787
+ t: undefined
2788
+ });
2789
+ return _el$;
2790
+ })();
2791
+ }
2792
+ function showDialog(api, signal, render) {
2793
+ return new Promise((resolve) => {
2794
+ let finished = false;
2795
+ const onAbort = () => finish(undefined);
2796
+ const finish = (value, clearDialog = true) => {
2797
+ if (finished)
2137
2798
  return;
2138
- });
2139
- throw cause;
2799
+ finished = true;
2800
+ signal.removeEventListener("abort", onAbort);
2801
+ if (clearDialog)
2802
+ api.ui.dialog.clear();
2803
+ resolve(value);
2804
+ };
2805
+ if (signal.aborted) {
2806
+ finish(undefined, false);
2807
+ return;
2140
2808
  }
2141
- try {
2142
- await lock.value.release();
2143
- } catch (cause) {
2809
+ signal.addEventListener("abort", onAbort, {
2810
+ once: true
2811
+ });
2812
+ api.ui.dialog.setSize("medium");
2813
+ api.ui.dialog.replace(() => render((value) => finish(value)), () => finish(undefined, false));
2814
+ });
2815
+ }
2816
+ function selectFeedbackKind(api, signal) {
2817
+ return showDialog(api, signal, (finish) => {
2818
+ const DialogSelect = api.ui.DialogSelect;
2819
+ return _$createComponent(DialogSelect, {
2820
+ title: "Feedback type",
2821
+ options: [{
2822
+ title: "Bug report",
2823
+ value: "bug"
2824
+ }, {
2825
+ title: "Feature request",
2826
+ value: "feature"
2827
+ }, {
2828
+ title: "Other feedback",
2829
+ value: "other"
2830
+ }],
2831
+ current: "bug",
2832
+ onSelect: (option) => finish(option.value)
2833
+ });
2834
+ });
2835
+ }
2836
+ function promptForValue(api, signal, title, required) {
2837
+ return showDialog(api, signal, (finish) => {
2838
+ const [error, setError] = createSignal();
2839
+ const DialogPrompt = api.ui.DialogPrompt;
2840
+ return _$createComponent(DialogPrompt, {
2841
+ title,
2842
+ description: () => error() ? (() => {
2843
+ var _el$0 = _$createElement("text");
2844
+ _$insert(_el$0, error);
2845
+ _$effect((_$p) => _$setProp(_el$0, "fg", api.theme.current.error, _$p));
2846
+ return _el$0;
2847
+ })() : null,
2848
+ onConfirm: (value) => {
2849
+ if (required && value.trim() === "") {
2850
+ setError(`${title} is required`);
2851
+ return;
2852
+ }
2853
+ finish(value);
2854
+ },
2855
+ onCancel: () => finish(undefined)
2856
+ });
2857
+ });
2858
+ }
2859
+ function confirmDiagnostics(api, signal, diagnostics) {
2860
+ const message = [`Plugin version: ${diagnostics.pluginVersion}`, `OpenCode version: ${diagnostics.opencodeVersion}`, `Operating system: ${diagnostics.operatingSystem}`, `Installation source: ${diagnostics.installationSource}`].join(`
2861
+ `);
2862
+ return showDialog(api, signal, (finish) => _$createComponent(api.ui.DialogConfirm, {
2863
+ title: "Include diagnostics?",
2864
+ message,
2865
+ onConfirm: () => finish(true),
2866
+ onCancel: () => finish(false)
2867
+ }));
2868
+ }
2869
+ function selectDelivery(api, signal) {
2870
+ return showDialog(api, signal, (finish) => {
2871
+ const DialogSelect = api.ui.DialogSelect;
2872
+ return _$createComponent(DialogSelect, {
2873
+ title: "Send feedback",
2874
+ options: [{
2875
+ title: "Open in browser",
2876
+ value: "browser"
2877
+ }, {
2878
+ title: "Submit with GitHub CLI",
2879
+ value: "gh"
2880
+ }],
2881
+ current: "browser",
2882
+ onSelect: (option) => finish(option.value)
2883
+ });
2884
+ });
2885
+ }
2886
+ function deliveryAction(delivery) {
2887
+ return delivery === "browser" ? "Open a prefilled issue in your browser" : "Create the issue with GitHub CLI";
2888
+ }
2889
+ function feedbackPreview(draft, delivery) {
2890
+ const label = delivery === "gh" ? "none" : draft.template === "bug_report.md" ? "bug" : draft.template === "feature_request.md" ? "enhancement" : "none";
2891
+ return ["Repository: hcrosse/opencode-pr-tracker", `Action: ${deliveryAction(delivery)}`, `Title: ${draft.title}`, `Label: ${label}`, "", "Body:", draft.body].join(`
2892
+ `);
2893
+ }
2894
+ function confirmFeedback(api, signal, draft, delivery, confirmationRenderer) {
2895
+ const title = delivery === "browser" ? "Open PR tracker feedback?" : "Send PR tracker feedback?";
2896
+ const confirmLabel = delivery === "browser" ? "[Enter] Open issue" : "[Enter] Send";
2897
+ return showDialog(api, signal, (finish) => {
2898
+ const Confirmation = confirmationRenderer;
2899
+ return _$createComponent(Confirmation, {
2900
+ title,
2901
+ confirmLabel,
2902
+ get preview() {
2903
+ return feedbackPreview(draft, delivery);
2904
+ },
2905
+ onConfirm: () => finish(true),
2906
+ onCancel: () => finish(false)
2907
+ });
2908
+ });
2909
+ }
2910
+ async function collectFeedbackInput(api, signal, kind) {
2911
+ const title = await promptForValue(api, signal, "Feedback title", true);
2912
+ if (title === undefined)
2913
+ return;
2914
+ switch (kind) {
2915
+ case "bug": {
2916
+ const problem = await promptForValue(api, signal, "Problem", true);
2917
+ if (problem === undefined)
2918
+ return;
2919
+ const reproduction = await promptForValue(api, signal, "Reproduction steps", true);
2920
+ if (reproduction === undefined)
2921
+ return;
2922
+ const expectedBehavior = await promptForValue(api, signal, "Expected behavior", true);
2923
+ if (expectedBehavior === undefined)
2924
+ return;
2144
2925
  return {
2145
- ok: false,
2146
- error: stateUnavailable("write", "Unable to unlock the session pull request state", cause)
2926
+ kind,
2927
+ title,
2928
+ problem,
2929
+ reproduction,
2930
+ expectedBehavior
2147
2931
  };
2148
2932
  }
2149
- const compromise = lock.value.compromised();
2150
- if (compromise !== undefined) {
2933
+ case "feature": {
2934
+ const problem = await promptForValue(api, signal, "Problem", true);
2935
+ if (problem === undefined)
2936
+ return;
2937
+ const desiredOutcome = await promptForValue(api, signal, "Desired outcome", true);
2938
+ if (desiredOutcome === undefined)
2939
+ return;
2940
+ const constraints = await promptForValue(api, signal, "Constraints (optional)", false);
2941
+ if (constraints === undefined)
2942
+ return;
2151
2943
  return {
2152
- ok: false,
2153
- error: stateUnavailable("write", "The session pull request state lock was compromised", compromise)
2944
+ kind,
2945
+ title,
2946
+ problem,
2947
+ desiredOutcome,
2948
+ constraints
2154
2949
  };
2155
2950
  }
2156
- return result;
2157
- }
2158
- async function readExisting(sessionID) {
2159
- const path = join(directory, fileName(sessionID));
2160
- let content;
2161
- try {
2162
- content = await readFile(path, "utf8");
2163
- } catch (cause) {
2164
- if (isMissingFile(cause))
2165
- return { ok: true, value: undefined };
2951
+ case "other": {
2952
+ const details = await promptForValue(api, signal, "Details", true);
2953
+ if (details === undefined)
2954
+ return;
2166
2955
  return {
2167
- ok: false,
2168
- error: stateUnavailable("read", "Unable to read the session pull request state", cause)
2956
+ kind,
2957
+ title,
2958
+ details
2169
2959
  };
2170
2960
  }
2171
- let decoded;
2172
- try {
2173
- decoded = JSON.parse(content);
2174
- } catch {
2175
- return invalidStateFile;
2176
- }
2177
- return parseState(decoded);
2178
- }
2179
- async function read(sessionID) {
2180
- const result = await readExisting(sessionID);
2181
- if (!result.ok)
2182
- return result;
2183
- return { ok: true, value: result.value ?? [] };
2961
+ default:
2962
+ return casesHandled(kind);
2184
2963
  }
2185
- async function write(sessionID, attachments) {
2186
- const destination = join(directory, fileName(sessionID));
2187
- const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
2188
- const state = {
2189
- version: 1,
2190
- pullRequests: attachments.map((attachment) => ({
2191
- url: attachment.pullRequest.url,
2192
- attachedAt: attachment.attachedAt
2193
- }))
2194
- };
2195
- try {
2196
- await mkdir(directory, { recursive: true });
2197
- await writeFile(temporary, `${JSON.stringify(state, null, 2)}
2198
- `, { mode: 384 });
2199
- await rename(temporary, destination);
2200
- return { ok: true, value: undefined };
2201
- } catch (cause) {
2202
- await rm(temporary, { force: true }).catch(() => {
2964
+ }
2965
+ function createFeedbackCommand(api, dependencies, release) {
2966
+ return {
2967
+ name: "pr.tracker.feedback",
2968
+ title: "Send PR tracker feedback",
2969
+ category: "Plugin",
2970
+ namespace: "palette",
2971
+ slashName: "pr-tracker-feedback",
2972
+ async run() {
2973
+ const signal = api.lifecycle.signal;
2974
+ const kind = await selectFeedbackKind(api, signal);
2975
+ if (kind === undefined)
2203
2976
  return;
2204
- });
2205
- return {
2206
- ok: false,
2207
- error: stateUnavailable("write", "Unable to write the session pull request state", cause)
2977
+ const input = await collectFeedbackInput(api, signal, kind);
2978
+ if (input === undefined)
2979
+ return;
2980
+ const diagnostics = {
2981
+ pluginVersion: release?.version ?? "unavailable",
2982
+ opencodeVersion: api.app.version,
2983
+ operatingSystem: `${process.platform}/${process.arch}`,
2984
+ installationSource: release?.source ?? "unavailable"
2208
2985
  };
2209
- }
2210
- }
2211
- return {
2212
- list: read,
2213
- async attach(sessionID, pullRequest) {
2214
- return withLock(sessionID, async () => {
2215
- const current = await read(sessionID);
2216
- if (!current.ok)
2217
- return current;
2218
- if (current.value.some((attachment) => attachment.pullRequest.url === pullRequest.url)) {
2219
- return { ok: true, value: "already_attached" };
2220
- }
2221
- if (current.value.length >= maximumPullRequestsPerSession) {
2222
- return {
2223
- ok: false,
2224
- error: {
2225
- tag: "AttachmentLimitReached",
2226
- limit: maximumPullRequestsPerSession,
2227
- message: "A session can track at most 20 pull requests"
2228
- }
2229
- };
2230
- }
2231
- const written = await write(sessionID, [...current.value, { pullRequest, attachedAt: now().toISOString() }]);
2232
- if (!written.ok)
2233
- return written;
2234
- return { ok: true, value: "added" };
2235
- });
2236
- },
2237
- async detach(sessionID, pullRequest) {
2238
- return withLock(sessionID, async () => {
2239
- const current = await read(sessionID);
2240
- if (!current.ok)
2241
- return current;
2242
- const next = current.value.filter((attachment) => attachment.pullRequest.url !== pullRequest.url);
2243
- if (next.length === current.value.length)
2244
- return { ok: true, value: "absent" };
2245
- const written = await write(sessionID, next);
2246
- if (!written.ok)
2247
- return written;
2248
- return { ok: true, value: "removed" };
2249
- });
2250
- },
2251
- async detachByNumber(sessionID, number) {
2252
- return withLock(sessionID, async () => {
2253
- const current = await read(sessionID);
2254
- if (!current.ok)
2255
- return current;
2256
- const matches = current.value.filter((attachment) => attachment.pullRequest.number === number);
2257
- if (matches.length === 0)
2258
- return { ok: true, value: { tag: "absent" } };
2259
- if (matches.length > 1) {
2260
- return {
2261
- ok: true,
2262
- value: { tag: "ambiguous", pullRequests: matches.map((attachment) => attachment.pullRequest) }
2263
- };
2986
+ const includeDiagnostics = await confirmDiagnostics(api, signal, diagnostics);
2987
+ if (includeDiagnostics === undefined)
2988
+ return;
2989
+ const draft = createFeedbackDraft(input, includeDiagnostics ? diagnostics : undefined);
2990
+ if (!draft.ok) {
2991
+ api.ui.toast({
2992
+ variant: "error",
2993
+ title: "Pull request tracker",
2994
+ message: draft.error.message
2995
+ });
2996
+ return;
2997
+ }
2998
+ const delivery = await selectDelivery(api, signal);
2999
+ if (delivery === undefined)
3000
+ return;
3001
+ const confirmed = await confirmFeedback(api, signal, draft.value, delivery, dependencies.confirmationRenderer ?? FeedbackConfirmation);
3002
+ if (!confirmed || signal.aborted)
3003
+ return;
3004
+ if (delivery === "browser") {
3005
+ const result2 = await openFeedbackDraft(draft.value, {
3006
+ ...dependencies.platform === undefined ? {} : {
3007
+ platform: dependencies.platform
3008
+ },
3009
+ ...dependencies.runner === undefined ? {} : {
3010
+ runner: dependencies.runner
3011
+ },
3012
+ signal
3013
+ });
3014
+ if (!result2.ok && !signal.aborted) {
3015
+ api.ui.toast({
3016
+ variant: "error",
3017
+ title: "Pull request tracker",
3018
+ message: result2.error.message
3019
+ });
2264
3020
  }
2265
- const match = matches[0];
2266
- if (match === undefined)
2267
- return { ok: true, value: { tag: "absent" } };
2268
- const next = current.value.filter((attachment) => attachment.pullRequest.url !== match.pullRequest.url);
2269
- const written = await write(sessionID, next);
2270
- if (!written.ok)
2271
- return written;
2272
- return { ok: true, value: { tag: "removed", pullRequest: match.pullRequest } };
3021
+ return;
3022
+ }
3023
+ const result = await submitFeedbackDraft(draft.value, {
3024
+ ...dependencies.runner === undefined ? {} : {
3025
+ runner: dependencies.runner
3026
+ },
3027
+ signal
2273
3028
  });
2274
- },
2275
- async removeSession(sessionID) {
2276
- return withLock(sessionID, async () => {
2277
- const current = await readExisting(sessionID);
2278
- if (!current.ok)
2279
- return current;
2280
- if (current.value === undefined)
2281
- return { ok: true, value: "absent" };
2282
- try {
2283
- await rm(join(directory, fileName(sessionID)), { force: true });
2284
- return { ok: true, value: "removed" };
2285
- } catch (cause) {
2286
- return {
2287
- ok: false,
2288
- error: stateUnavailable("write", "Unable to remove the session pull request state", cause)
2289
- };
3029
+ if (result.ok) {
3030
+ if (!signal.aborted) {
3031
+ api.ui.toast({
3032
+ variant: "success",
3033
+ title: "Pull request tracker",
3034
+ message: result.value
3035
+ });
2290
3036
  }
2291
- });
3037
+ } else if (result.error.tag !== "SubmitFeedbackCancelled" && !signal.aborted) {
3038
+ api.ui.toast({
3039
+ variant: "error",
3040
+ title: "Pull request tracker",
3041
+ message: result.error.message
3042
+ });
3043
+ }
2292
3044
  }
2293
3045
  };
2294
3046
  }
2295
3047
 
2296
- // src/tui.tsx
2297
- var pollIntervalMilliseconds = 60000;
2298
- var defaultScheduler = {
2299
- setInterval: (task, delay) => globalThis.setInterval(task, delay),
2300
- clearInterval: (handle) => globalThis.clearInterval(handle)
2301
- };
2302
- function attachPullRequest(store, sessionID, input) {
2303
- const pullRequest = parsePullRequestUrl(input);
2304
- if (!pullRequest.ok)
2305
- return Promise.resolve(pullRequest);
2306
- return store.attach(sessionID, pullRequest.value);
3048
+ // src/plugin-update-tui.tsx
3049
+ import { createComponent as _$createComponent2 } from "@opentui/solid";
3050
+ import { join as join2 } from "path";
3051
+
3052
+ // src/update.ts
3053
+ import { readFile } from "fs/promises";
3054
+ import { join } from "path";
3055
+ var updateCacheKey = "plugin-update-check-v1";
3056
+ var packageName = "@hcrosse/opencode-pr-tracker";
3057
+ var registryUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
3058
+ var updateCacheMilliseconds = 24 * 60 * 60 * 1000;
3059
+ function isRecord2(value) {
3060
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2307
3061
  }
2308
- function startSessionPolling(input) {
2309
- const scheduler = input.scheduler ?? defaultScheduler;
2310
- const statuses = new Map;
2311
- const controller = new AbortController;
2312
- let timer;
2313
- let timerRegistered = false;
2314
- let stopped = false;
2315
- let inFlight;
2316
- let refreshQueued = false;
2317
- function project(attachments) {
2318
- return attachments.map((attachment) => ({
2319
- attachment,
2320
- status: statuses.get(attachment.pullRequest.url) ?? {
2321
- tag: "Unavailable"
2322
- }
2323
- }));
3062
+ function parseRegistryReleases(input) {
3063
+ if (!isRecord2(input) || !isRecord2(input.versions))
3064
+ return;
3065
+ const releases = [];
3066
+ for (const [version, metadata] of Object.entries(input.versions)) {
3067
+ if (!isRecord2(metadata) || !isRecord2(metadata.engines) || typeof metadata.engines.opencode !== "string")
3068
+ continue;
3069
+ releases.push({ version, opencodeRange: metadata.engines.opencode });
2324
3070
  }
2325
- async function poll() {
2326
- const attachments = await input.store.list(input.sessionID);
2327
- if (stopped)
2328
- return;
2329
- if (!attachments.ok) {
2330
- input.publish([]);
2331
- input.onStateFailure(attachments.error);
2332
- return;
2333
- }
2334
- const attachedUrls = new Set(attachments.value.map((attachment) => attachment.pullRequest.url));
2335
- for (const url of statuses.keys()) {
2336
- if (!attachedUrls.has(url))
2337
- statuses.delete(url);
2338
- }
2339
- input.publish(project(attachments.value));
2340
- const refreshable = attachments.value.filter((attachment) => {
2341
- const previous = statuses.get(attachment.pullRequest.url);
2342
- return previous?.tag !== "Available" || previous.state.tag !== "Merged";
2343
- });
2344
- const batch = await input.github.get(refreshable.map((attachment) => attachment.pullRequest), {
2345
- signal: controller.signal
2346
- });
2347
- if (stopped)
2348
- return;
2349
- let batchDiagnostic;
2350
- if (!batch.ok) {
2351
- if (batch.error.tag === "GitHubCancelled")
2352
- return;
2353
- batchDiagnostic = batch.error.tag === "GitHubBatchLimitExceeded" ? "GitHubUnavailable" : batch.error.tag;
2354
- }
2355
- for (const [index, attachment] of refreshable.entries()) {
2356
- const previous = statuses.get(attachment.pullRequest.url);
2357
- const result = batch.ok ? batch.value[index] : undefined;
2358
- if (result?.ok) {
2359
- statuses.set(attachment.pullRequest.url, result.value);
3071
+ return releases;
3072
+ }
3073
+ function isStableVersion(version) {
3074
+ return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version);
3075
+ }
3076
+ function isNewerStableVersion(version, currentVersion) {
3077
+ if (!isStableVersion(version))
3078
+ return false;
3079
+ try {
3080
+ return Bun.semver.order(version, currentVersion) > 0;
3081
+ } catch {
3082
+ return false;
3083
+ }
3084
+ }
3085
+ function newestCompatibleUpdate(releases, versions) {
3086
+ let latest;
3087
+ for (const release of releases) {
3088
+ if (!isNewerStableVersion(release.version, versions.currentVersion))
3089
+ continue;
3090
+ try {
3091
+ if (!Bun.semver.satisfies(versions.opencodeVersion, release.opencodeRange))
2360
3092
  continue;
2361
- }
2362
- const diagnostic = result === undefined ? batchDiagnostic ?? "GitHubUnavailable" : result.error.tag;
2363
- statuses.set(attachment.pullRequest.url, previous?.tag === "Available" ? {
2364
- ...previous,
2365
- stale: true,
2366
- diagnostic
2367
- } : {
2368
- tag: "Unavailable",
2369
- diagnostic
2370
- });
3093
+ if (latest === undefined || Bun.semver.order(release.version, latest) > 0)
3094
+ latest = release.version;
3095
+ } catch {
3096
+ continue;
2371
3097
  }
2372
- if (!stopped)
2373
- input.publish(project(attachments.value));
2374
3098
  }
2375
- function refresh() {
2376
- if (stopped)
2377
- return Promise.resolve();
2378
- if (inFlight) {
2379
- refreshQueued = true;
2380
- return inFlight;
3099
+ return latest === undefined ? undefined : { currentVersion: versions.currentVersion, version: latest };
3100
+ }
3101
+ function isCancellation2(cause, signal) {
3102
+ return signal?.aborted === true || cause instanceof DOMException && cause.name === "AbortError";
3103
+ }
3104
+ async function checkForUpdate(versions, options = {}) {
3105
+ const fetcher = options.fetch ?? ((url, input) => globalThis.fetch(url, input));
3106
+ let response;
3107
+ try {
3108
+ response = await fetcher(registryUrl, options.signal ? { signal: options.signal } : {});
3109
+ } catch (cause) {
3110
+ if (isCancellation2(cause, options.signal)) {
3111
+ return { ok: false, error: { tag: "UpdateCheckCancelled", message: "Plugin update check cancelled" } };
2381
3112
  }
2382
- const wrapped = poll().finally(() => {
2383
- inFlight = undefined;
2384
- if (refreshQueued && !stopped) {
2385
- refreshQueued = false;
2386
- return refresh();
2387
- }
2388
- return;
2389
- });
2390
- inFlight = wrapped;
2391
- return wrapped;
3113
+ return {
3114
+ ok: false,
3115
+ error: { tag: "UpdateCheckUnavailable", message: "Unable to check for plugin updates", cause }
3116
+ };
2392
3117
  }
2393
- return {
2394
- start() {
2395
- if (stopped)
2396
- return Promise.resolve();
2397
- if (!timerRegistered) {
2398
- timer = scheduler.setInterval(() => {
2399
- refresh().catch(input.onError);
2400
- }, pollIntervalMilliseconds);
2401
- timerRegistered = true;
2402
- }
2403
- return refresh();
2404
- },
2405
- refresh,
2406
- stop() {
2407
- if (stopped)
2408
- return;
2409
- stopped = true;
2410
- controller.abort();
2411
- if (timerRegistered)
2412
- scheduler.clearInterval(timer);
2413
- }
2414
- };
2415
- }
2416
- async function openPullRequest(pullRequest, options = {}) {
2417
- const platform = options.platform ?? process.platform;
2418
- const executable = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : undefined;
2419
- if (executable === undefined) {
3118
+ if (!response.ok) {
2420
3119
  return {
2421
3120
  ok: false,
2422
3121
  error: {
2423
- tag: "UnsupportedPlatform",
2424
- message: `Opening pull requests is unsupported on ${platform}`,
2425
- platform
3122
+ tag: "UpdateCheckUnavailable",
3123
+ message: "Unable to check for plugin updates",
3124
+ cause: new Error(`npm registry returned HTTP ${response.status}`)
2426
3125
  }
2427
3126
  };
2428
3127
  }
3128
+ let decoded;
2429
3129
  try {
2430
- await (options.runner ?? execFileRunner)(executable, [pullRequest.url], options.signal ? {
2431
- signal: options.signal
2432
- } : {});
3130
+ decoded = await response.json();
3131
+ } catch {
2433
3132
  return {
2434
- ok: true,
2435
- value: undefined
3133
+ ok: false,
3134
+ error: {
3135
+ tag: "InvalidUpdateResponse",
3136
+ message: "The npm registry returned invalid plugin release metadata"
3137
+ }
2436
3138
  };
2437
- } catch (cause) {
3139
+ }
3140
+ const releases = parseRegistryReleases(decoded);
3141
+ if (releases === undefined) {
2438
3142
  return {
2439
3143
  ok: false,
2440
3144
  error: {
2441
- tag: "OpenPullRequestFailed",
2442
- message: "Unable to open the pull request",
2443
- cause
3145
+ tag: "InvalidUpdateResponse",
3146
+ message: "The npm registry returned invalid plugin release metadata"
2444
3147
  }
2445
3148
  };
2446
3149
  }
3150
+ return { ok: true, value: newestCompatibleUpdate(releases, versions) };
2447
3151
  }
2448
- function createRefreshBus() {
2449
- const listeners = new Map;
2450
- return {
2451
- emit(sessionID) {
2452
- for (const listener of listeners.get(sessionID) ?? [])
2453
- listener();
2454
- },
2455
- subscribe(sessionID, listener) {
2456
- const sessionListeners = listeners.get(sessionID) ?? new Set;
2457
- sessionListeners.add(listener);
2458
- listeners.set(sessionID, sessionListeners);
2459
- return () => {
2460
- sessionListeners.delete(listener);
2461
- if (sessionListeners.size === 0)
2462
- listeners.delete(sessionID);
2463
- };
3152
+ function parseFreshUpdateCache(input, versions, now = Date.now()) {
3153
+ if (!isRecord2(input))
3154
+ return;
3155
+ if (typeof input.checkedAt !== "number" || !Number.isFinite(input.checkedAt) || input.checkedAt > now || now - input.checkedAt >= updateCacheMilliseconds || input.currentVersion !== versions.currentVersion || input.opencodeVersion !== versions.opencodeVersion || input.availableVersion !== null && (typeof input.availableVersion !== "string" || !isNewerStableVersion(input.availableVersion, versions.currentVersion))) {
3156
+ return;
3157
+ }
3158
+ return input.availableVersion;
3159
+ }
3160
+ function isMissingFile(cause) {
3161
+ return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
3162
+ }
3163
+ async function readConfig(path) {
3164
+ try {
3165
+ return Bun.JSONC.parse(await readFile(path, "utf8"));
3166
+ } catch (cause) {
3167
+ if (isMissingFile(cause))
3168
+ return;
3169
+ return;
3170
+ }
3171
+ }
3172
+ function configuredPluginSpecifier(input) {
3173
+ if (typeof input === "string")
3174
+ return input;
3175
+ if (Array.isArray(input) && input.length === 2 && typeof input[0] === "string" && isRecord2(input[1])) {
3176
+ return input[0];
3177
+ }
3178
+ return;
3179
+ }
3180
+ function containsPlugin(input) {
3181
+ if (!isRecord2(input) || !Array.isArray(input.plugin))
3182
+ return false;
3183
+ return input.plugin.some((entry) => {
3184
+ const spec = configuredPluginSpecifier(entry);
3185
+ return spec !== undefined && (spec === packageName || spec.startsWith(`${packageName}/`) || spec.startsWith(`${packageName}@`));
3186
+ });
3187
+ }
3188
+ async function containsConfiguredPlugin(directory) {
3189
+ const configs = await Promise.all(["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"].map((name) => readConfig(join(directory, name))));
3190
+ return configs.some(containsPlugin);
3191
+ }
3192
+ async function detectInstallationScopes(input) {
3193
+ const [project, global2] = await Promise.all([
3194
+ containsConfiguredPlugin(input.projectConfigDirectory),
3195
+ containsConfiguredPlugin(input.globalConfigDirectory)
3196
+ ]);
3197
+ return [...project ? ["project"] : [], ...global2 ? ["global"] : []];
3198
+ }
3199
+ function updateCommand(version, scope) {
3200
+ const globalFlag = scope === "global" ? " --global" : "";
3201
+ return `opencode plugin ${packageName}@${version}${globalFlag} --force`;
3202
+ }
3203
+ function formatUpdateInstructions(version, scopes) {
3204
+ const commands = scopes.length === 0 ? ["project", "global"] : scopes;
3205
+ const body = commands.length === 1 ? updateCommand(version, commands[0]) : commands.map((scope) => `${scope === "project" ? "Project" : "Global"} installation:
3206
+ ${updateCommand(version, scope)}`).join(`
3207
+
3208
+ `);
3209
+ return `${body}
3210
+
3211
+ Restart OpenCode after updating.`;
3212
+ }
3213
+
3214
+ // src/plugin-update-tui.tsx
3215
+ function createUpdateBus() {
3216
+ const listeners = new Set;
3217
+ let version;
3218
+ return {
3219
+ current: () => version,
3220
+ publish(value) {
3221
+ version = value;
3222
+ for (const listener of listeners)
3223
+ listener(value);
3224
+ },
3225
+ subscribe(listener) {
3226
+ listeners.add(listener);
3227
+ return () => listeners.delete(listener);
2464
3228
  }
2465
3229
  };
2466
3230
  }
2467
- function currentSessionID(api) {
2468
- const route = api.route.current;
2469
- if (route.name !== "session" || !("params" in route))
2470
- return;
2471
- return typeof route.params?.sessionID === "string" ? route.params.sessionID : undefined;
3231
+ function writeUpdateCache(api, versions, availableVersion, now) {
3232
+ const cache = {
3233
+ checkedAt: now(),
3234
+ ...versions,
3235
+ availableVersion
3236
+ };
3237
+ api.kv.set(updateCacheKey, cache);
2472
3238
  }
2473
- function promptForPullRequest(api, options) {
3239
+ function waitForKvReady(api) {
2474
3240
  return new Promise((resolve) => {
2475
- const controller = new AbortController;
2476
- const signal = AbortSignal.any([options.signal, controller.signal]);
2477
- let finished = false;
2478
- const finish = (value) => {
2479
- if (finished)
2480
- return;
2481
- finished = true;
2482
- controller.abort();
2483
- api.ui.dialog.clear();
2484
- resolve(value);
3241
+ let timer;
3242
+ const finish = (ready) => {
3243
+ if (timer !== undefined)
3244
+ clearTimeout(timer);
3245
+ api.lifecycle.signal.removeEventListener("abort", onAbort);
3246
+ resolve(ready);
2485
3247
  };
2486
- api.ui.dialog.setSize("medium");
2487
- api.ui.dialog.replace(() => {
2488
- const [error, setError] = createSignal();
2489
- const [busy, setBusy] = createSignal(false);
2490
- const DialogPrompt = api.ui.DialogPrompt;
2491
- return _$createComponent(DialogPrompt, {
2492
- title: "Attach pull request",
2493
- placeholder: "https://github.com/owner/repository/pull/123 or 123",
2494
- description: () => error() ? (() => {
2495
- var _el$ = _$createElement("text");
2496
- _$insert(_el$, error);
2497
- _$effect((_$p) => _$setProp(_el$, "fg", api.theme.current.error, _$p));
2498
- return _el$;
2499
- })() : null,
2500
- get busy() {
2501
- return busy();
2502
- },
2503
- busyText: "Resolving repository",
2504
- onConfirm: (value) => {
2505
- if (busy())
2506
- return;
2507
- setBusy(true);
2508
- resolvePullRequestInput(value, {
2509
- directory: options.directory,
2510
- ...options.runner ? {
2511
- runner: options.runner
2512
- } : {},
2513
- signal
2514
- }).then((result) => {
2515
- if (finished)
2516
- return;
2517
- setBusy(false);
2518
- if (result.ok) {
2519
- finish(result.value);
2520
- return;
2521
- }
2522
- if (result.error.tag === "RepositoryResolutionCancelled") {
2523
- finish(undefined);
2524
- return;
2525
- }
2526
- setError(result.error.message);
2527
- });
2528
- },
2529
- onCancel: () => finish(undefined)
2530
- });
2531
- }, () => {
2532
- if (finished)
2533
- return;
2534
- finished = true;
2535
- controller.abort();
2536
- resolve(undefined);
3248
+ const onAbort = () => finish(false);
3249
+ const check = () => {
3250
+ if (api.lifecycle.signal.aborted) {
3251
+ finish(false);
3252
+ } else if (api.kv.ready) {
3253
+ finish(true);
3254
+ } else {
3255
+ timer = setTimeout(check, 10);
3256
+ }
3257
+ };
3258
+ api.lifecycle.signal.addEventListener("abort", onAbort, {
3259
+ once: true
2537
3260
  });
3261
+ check();
2538
3262
  });
2539
3263
  }
2540
- function selectPullRequest(api, title, attachments) {
2541
- return new Promise((resolve) => {
2542
- let finished = false;
2543
- const finish = (value) => {
2544
- if (finished)
3264
+ function updateStatusLabel(version) {
3265
+ return `${version} available`;
3266
+ }
3267
+ function createPluginUpdateController(api, dependencies, release) {
3268
+ const updateBus = createUpdateBus();
3269
+ const updateChecker = dependencies.updateChecker ?? checkForUpdate;
3270
+ const installationScopes = dependencies.installationScopes ?? detectInstallationScopes;
3271
+ const now = dependencies.now ?? Date.now;
3272
+ const versions = release?.source === "npm" && release.version !== undefined ? {
3273
+ currentVersion: release.version,
3274
+ opencodeVersion: api.app.version
3275
+ } : undefined;
3276
+ let updateOperations = Promise.resolve();
3277
+ function serializeUpdateOperation(operation) {
3278
+ const result = updateOperations.then(operation);
3279
+ updateOperations = result.then(() => {
3280
+ return;
3281
+ }, () => {
3282
+ return;
3283
+ });
3284
+ return result;
3285
+ }
3286
+ let startup;
3287
+ if (versions !== undefined) {
3288
+ startup = serializeUpdateOperation(async () => {
3289
+ try {
3290
+ if (!await waitForKvReady(api))
3291
+ return;
3292
+ const cached = parseFreshUpdateCache(api.kv.get(updateCacheKey), versions, now());
3293
+ if (cached !== undefined) {
3294
+ updateBus.publish(cached ?? undefined);
3295
+ return;
3296
+ }
3297
+ const result = await updateChecker(versions, {
3298
+ signal: api.lifecycle.signal
3299
+ });
3300
+ if (api.lifecycle.signal.aborted)
3301
+ return;
3302
+ const availableVersion = result.ok ? result.value?.version ?? null : null;
3303
+ writeUpdateCache(api, versions, availableVersion, now);
3304
+ if (result.ok)
3305
+ updateBus.publish(result.value?.version);
3306
+ } catch {
3307
+ if (!api.lifecycle.signal.aborted && api.kv.ready)
3308
+ writeUpdateCache(api, versions, null, now);
3309
+ }
3310
+ });
3311
+ }
3312
+ const command = {
3313
+ name: "pr.tracker.plugin.update",
3314
+ title: "Update PR tracker plugin",
3315
+ category: "Plugin",
3316
+ namespace: "palette",
3317
+ slashName: "pr-tracker-plugin-update",
3318
+ async run() {
3319
+ if (versions === undefined) {
3320
+ api.ui.toast({
3321
+ variant: "info",
3322
+ title: "Pull request tracker",
3323
+ message: "Update checks are unavailable for this plugin installation"
3324
+ });
2545
3325
  return;
2546
- finished = true;
2547
- api.ui.dialog.clear();
2548
- resolve(value);
3326
+ }
3327
+ const result = await serializeUpdateOperation(async () => {
3328
+ if (!await waitForKvReady(api))
3329
+ return;
3330
+ const checked = await updateChecker(versions, {
3331
+ signal: api.lifecycle.signal
3332
+ });
3333
+ if (api.lifecycle.signal.aborted || !checked.ok && checked.error.tag === "UpdateCheckCancelled") {
3334
+ return;
3335
+ }
3336
+ if (checked.ok) {
3337
+ writeUpdateCache(api, versions, checked.value?.version ?? null, now);
3338
+ updateBus.publish(checked.value?.version);
3339
+ }
3340
+ return checked;
3341
+ });
3342
+ if (result === undefined)
3343
+ return;
3344
+ if (!result.ok) {
3345
+ api.ui.toast({
3346
+ variant: "error",
3347
+ title: "Pull request tracker",
3348
+ message: result.error.message
3349
+ });
3350
+ return;
3351
+ }
3352
+ if (result.value === undefined) {
3353
+ api.ui.toast({
3354
+ variant: "info",
3355
+ title: "Pull request tracker",
3356
+ message: "PR tracker is up to date"
3357
+ });
3358
+ return;
3359
+ }
3360
+ const update = result.value;
3361
+ let scopes;
3362
+ try {
3363
+ const projectRoot = api.state.path.worktree === "/" ? api.state.path.directory : api.state.path.worktree;
3364
+ scopes = await installationScopes({
3365
+ projectConfigDirectory: join2(projectRoot, ".opencode"),
3366
+ globalConfigDirectory: api.state.path.config
3367
+ });
3368
+ } catch {
3369
+ scopes = [];
3370
+ }
3371
+ if (api.lifecycle.signal.aborted)
3372
+ return;
3373
+ api.ui.dialog.setSize("medium");
3374
+ api.ui.dialog.replace(() => _$createComponent2(api.ui.DialogAlert, {
3375
+ get title() {
3376
+ return `Update PR tracker to ${update.version}`;
3377
+ },
3378
+ get message() {
3379
+ return formatUpdateInstructions(update.version, scopes);
3380
+ }
3381
+ }));
3382
+ }
3383
+ };
3384
+ return {
3385
+ command,
3386
+ current: updateBus.current,
3387
+ subscribe: updateBus.subscribe,
3388
+ startup
3389
+ };
3390
+ }
3391
+
3392
+ // src/pull-request-tui.tsx
3393
+ import { createTextNode as _$createTextNode2 } from "@opentui/solid";
3394
+ import { insertNode as _$insertNode2 } from "@opentui/solid";
3395
+ import { memo as _$memo } from "@opentui/solid";
3396
+ import { setProp as _$setProp2 } from "@opentui/solid";
3397
+ import { effect as _$effect2 } from "@opentui/solid";
3398
+ import { insert as _$insert2 } from "@opentui/solid";
3399
+ import { createElement as _$createElement2 } from "@opentui/solid";
3400
+ import { createComponent as _$createComponent3 } from "@opentui/solid";
3401
+ import { TextAttributes } from "@opentui/core";
3402
+ import { createSignal as createSignal2, onCleanup } from "solid-js";
3403
+
3404
+ // src/attach.ts
3405
+ async function attachPullRequest(dependencies, sessionID, pullRequest, options = {}) {
3406
+ return dependencies.store.attach(sessionID, pullRequest, {
3407
+ async validate() {
3408
+ const batch = await dependencies.github.get([pullRequest], options);
3409
+ if (!batch.ok)
3410
+ return batch;
3411
+ const item = batch.value[0];
3412
+ if (item === undefined)
3413
+ throw new Error("GitHub client omitted the requested pull request");
3414
+ if (!item.ok)
3415
+ return item;
3416
+ return { ok: true, value: undefined };
3417
+ }
3418
+ });
3419
+ }
3420
+ var invalidPullRequestInput = {
3421
+ ok: false,
3422
+ error: {
3423
+ tag: "InvalidPullRequestInput",
3424
+ message: "Expected https://github.com/<owner>/<repository>/pull/<positive-integer>, github.com/<owner>/<repository>/pull/<positive-integer>, or a positive pull request number"
3425
+ }
3426
+ };
3427
+ var repositoryResolutionFailed = {
3428
+ tag: "RepositoryResolutionFailed",
3429
+ message: "Unable to resolve the current GitHub repository with gh; attach with a full URL instead"
3430
+ };
3431
+ var repositoryResolutionCancelled = {
3432
+ ok: false,
3433
+ error: { tag: "RepositoryResolutionCancelled" }
3434
+ };
3435
+ function isCancellation3(cause, signal) {
3436
+ if (signal?.aborted)
3437
+ return true;
3438
+ return cause instanceof Error && cause.name === "AbortError";
3439
+ }
3440
+ function parseRepositoryPullRequest(stdout, number) {
3441
+ let decoded;
3442
+ try {
3443
+ decoded = JSON.parse(stdout);
3444
+ } catch {
3445
+ return { ok: false, error: repositoryResolutionFailed };
3446
+ }
3447
+ if (decoded === null || typeof decoded !== "object" || !("url" in decoded) || typeof decoded.url !== "string" || decoded.url === "") {
3448
+ return { ok: false, error: repositoryResolutionFailed };
3449
+ }
3450
+ const repositoryUrl = decoded.url.endsWith("/") ? decoded.url.slice(0, -1) : decoded.url;
3451
+ const pullRequest = parsePullRequestUrl(`${repositoryUrl}/pull/${number}`);
3452
+ return pullRequest.ok ? pullRequest : { ok: false, error: repositoryResolutionFailed };
3453
+ }
3454
+ async function resolvePullRequestInput(input, options) {
3455
+ const direct = parsePullRequestUrl(input);
3456
+ if (direct.ok)
3457
+ return direct;
3458
+ if (input.trim() !== input || !/^\d+$/.test(input))
3459
+ return invalidPullRequestInput;
3460
+ const number = Number(input);
3461
+ if (!Number.isSafeInteger(number) || number <= 0)
3462
+ return invalidPullRequestInput;
3463
+ let stdout;
3464
+ try {
3465
+ const result = await (options.runner ?? execFileRunner)("gh", ["repo", "view", "--json", "url"], {
3466
+ cwd: options.directory,
3467
+ ...options.signal ? { signal: options.signal } : {}
3468
+ });
3469
+ stdout = result.stdout;
3470
+ } catch (cause) {
3471
+ if (isCancellation3(cause, options.signal))
3472
+ return repositoryResolutionCancelled;
3473
+ return { ok: false, error: { ...repositoryResolutionFailed, cause } };
3474
+ }
3475
+ return parseRepositoryPullRequest(stdout, number);
3476
+ }
3477
+
3478
+ // src/polling.ts
3479
+ var pollIntervalMilliseconds = 60000;
3480
+ var defaultScheduler = {
3481
+ setInterval: (task, delay) => globalThis.setInterval(task, delay),
3482
+ clearInterval: (handle) => globalThis.clearInterval(handle)
3483
+ };
3484
+ function startSessionPolling(input) {
3485
+ const scheduler = input.scheduler ?? defaultScheduler;
3486
+ const statuses = new Map;
3487
+ const controller = new AbortController;
3488
+ let timer;
3489
+ let timerRegistered = false;
3490
+ let stopped = false;
3491
+ let inFlight;
3492
+ let queued;
3493
+ function project(attachments) {
3494
+ return attachments.map((attachment) => ({
3495
+ attachment,
3496
+ status: statuses.get(attachment.pullRequest.url) ?? { tag: "Unavailable" }
3497
+ }));
3498
+ }
3499
+ async function poll() {
3500
+ const attachments = await input.store.list(input.sessionID);
3501
+ if (stopped)
3502
+ return { ok: true, value: "stopped" };
3503
+ if (!attachments.ok) {
3504
+ input.publish([]);
3505
+ input.onStateFailure(attachments.error);
3506
+ return attachments;
3507
+ }
3508
+ const attachedUrls = new Set(attachments.value.map((attachment) => attachment.pullRequest.url));
3509
+ for (const url of statuses.keys()) {
3510
+ if (!attachedUrls.has(url))
3511
+ statuses.delete(url);
3512
+ }
3513
+ input.publish(project(attachments.value));
3514
+ if (attachments.value.length === 0)
3515
+ return { ok: true, value: "no_attachments" };
3516
+ const refreshable = attachments.value.filter((attachment) => {
3517
+ const previous = statuses.get(attachment.pullRequest.url);
3518
+ return previous?.tag !== "Available" || previous.state.tag !== "Merged";
3519
+ });
3520
+ const batch = await input.github.get(refreshable.map((attachment) => attachment.pullRequest), { signal: controller.signal });
3521
+ if (stopped)
3522
+ return { ok: true, value: "stopped" };
3523
+ let batchDiagnostic;
3524
+ let failure;
3525
+ if (!batch.ok) {
3526
+ if (batch.error.tag === "GitHubCancelled")
3527
+ return batch;
3528
+ batchDiagnostic = batch.error.tag === "GitHubBatchLimitExceeded" ? "GitHubUnavailable" : batch.error.tag;
3529
+ failure = batch.error;
3530
+ }
3531
+ for (const [index, attachment] of refreshable.entries()) {
3532
+ const previous = statuses.get(attachment.pullRequest.url);
3533
+ const result = batch.ok ? batch.value[index] : undefined;
3534
+ if (result?.ok) {
3535
+ statuses.set(attachment.pullRequest.url, result.value);
3536
+ continue;
3537
+ }
3538
+ const diagnostic = result === undefined ? batchDiagnostic ?? "GitHubUnavailable" : result.error.tag;
3539
+ if (result !== undefined && !result.ok)
3540
+ failure ??= result.error;
3541
+ statuses.set(attachment.pullRequest.url, previous?.tag === "Available" ? { ...previous, stale: true, diagnostic } : { tag: "Unavailable", diagnostic });
3542
+ }
3543
+ if (!stopped)
3544
+ input.publish(project(attachments.value));
3545
+ return failure === undefined ? { ok: true, value: "refreshed" } : { ok: false, error: failure };
3546
+ }
3547
+ function startQueuedRefresh() {
3548
+ inFlight = undefined;
3549
+ const next = queued;
3550
+ queued = undefined;
3551
+ if (next === undefined)
3552
+ return;
3553
+ if (stopped) {
3554
+ next.resolve({ ok: true, value: "stopped" });
3555
+ return;
3556
+ }
3557
+ requestRefresh().then((result) => next.resolve(result), (error) => next.reject(error));
3558
+ }
3559
+ function requestRefresh() {
3560
+ if (stopped)
3561
+ return Promise.resolve({ ok: true, value: "stopped" });
3562
+ if (inFlight) {
3563
+ if (queued !== undefined)
3564
+ return queued.promise;
3565
+ let resolve;
3566
+ let reject;
3567
+ const promise = new Promise((resolvePromise, rejectPromise) => {
3568
+ resolve = resolvePromise;
3569
+ reject = rejectPromise;
3570
+ });
3571
+ queued = { promise, resolve, reject };
3572
+ return promise;
3573
+ }
3574
+ const current = poll();
3575
+ inFlight = current;
3576
+ current.then(startQueuedRefresh, startQueuedRefresh);
3577
+ return current;
3578
+ }
3579
+ function scheduledRefresh() {
3580
+ return requestRefresh().then(() => {
3581
+ return;
3582
+ });
3583
+ }
3584
+ return {
3585
+ start() {
3586
+ if (stopped)
3587
+ return Promise.resolve();
3588
+ if (!timerRegistered) {
3589
+ timer = scheduler.setInterval(() => {
3590
+ scheduledRefresh().catch(input.onError);
3591
+ }, pollIntervalMilliseconds);
3592
+ timerRegistered = true;
3593
+ }
3594
+ return scheduledRefresh();
3595
+ },
3596
+ refresh: scheduledRefresh,
3597
+ forceRefresh: requestRefresh,
3598
+ stop() {
3599
+ if (stopped)
3600
+ return;
3601
+ stopped = true;
3602
+ controller.abort();
3603
+ if (timerRegistered)
3604
+ scheduler.clearInterval(timer);
3605
+ }
3606
+ };
3607
+ }
3608
+
3609
+ // src/pull-request-tui.tsx
3610
+ function attachPullRequest2(store, sessionID, input, options = {}) {
3611
+ const pullRequest = parsePullRequestUrl(input);
3612
+ if (!pullRequest.ok)
3613
+ return Promise.resolve(pullRequest);
3614
+ return attachPullRequest({
3615
+ store,
3616
+ github: options.github ?? createGitHubClient()
3617
+ }, sessionID, pullRequest.value, options.signal ? {
3618
+ signal: options.signal
3619
+ } : {});
3620
+ }
3621
+ function createRefreshBus() {
3622
+ const listeners = new Map;
3623
+ return {
3624
+ emit(sessionID) {
3625
+ for (const listener of listeners.get(sessionID) ?? [])
3626
+ listener.refresh();
3627
+ },
3628
+ async forceRefresh(sessionID) {
3629
+ const sessionListeners = listeners.get(sessionID);
3630
+ if (sessionListeners === undefined || sessionListeners.size === 0)
3631
+ return;
3632
+ const settled = await Promise.allSettled([...sessionListeners].map((listener) => listener.forceRefresh()));
3633
+ const rejected = settled.find((result) => result.status === "rejected");
3634
+ if (rejected !== undefined)
3635
+ throw rejected.reason;
3636
+ const results = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
3637
+ return results.find((result) => !result.ok) ?? results.find((result) => result.ok && result.value === "refreshed") ?? results[0];
3638
+ },
3639
+ subscribe(sessionID, listener) {
3640
+ const sessionListeners = listeners.get(sessionID) ?? new Set;
3641
+ sessionListeners.add(listener);
3642
+ listeners.set(sessionID, sessionListeners);
3643
+ return () => {
3644
+ sessionListeners.delete(listener);
3645
+ if (sessionListeners.size === 0)
3646
+ listeners.delete(sessionID);
3647
+ };
3648
+ }
3649
+ };
3650
+ }
3651
+ function currentSessionID(api) {
3652
+ const route = api.route.current;
3653
+ if (route.name !== "session" || !("params" in route))
3654
+ return;
3655
+ return typeof route.params?.sessionID === "string" ? route.params.sessionID : undefined;
3656
+ }
3657
+ function promptForPullRequest(api, options) {
3658
+ return new Promise((resolve) => {
3659
+ const controller = new AbortController;
3660
+ const signal = AbortSignal.any([options.signal, controller.signal]);
3661
+ let finished = false;
3662
+ const onAbort = () => finish(undefined);
3663
+ const finish = (value, clearDialog = true) => {
3664
+ if (finished)
3665
+ return;
3666
+ finished = true;
3667
+ options.signal.removeEventListener("abort", onAbort);
3668
+ controller.abort();
3669
+ if (clearDialog)
3670
+ api.ui.dialog.clear();
3671
+ resolve(value);
3672
+ };
3673
+ if (options.signal.aborted) {
3674
+ finish(undefined, false);
3675
+ return;
3676
+ }
3677
+ options.signal.addEventListener("abort", onAbort, {
3678
+ once: true
3679
+ });
3680
+ api.ui.dialog.setSize("medium");
3681
+ api.ui.dialog.replace(() => {
3682
+ const [error, setError] = createSignal2();
3683
+ const [busy, setBusy] = createSignal2(false);
3684
+ const DialogPrompt = api.ui.DialogPrompt;
3685
+ return _$createComponent3(DialogPrompt, {
3686
+ title: "Attach pull request",
3687
+ placeholder: "https://github.com/owner/repository/pull/123, github.com/owner/repository/pull/123, or 123",
3688
+ description: () => error() ? (() => {
3689
+ var _el$ = _$createElement2("text");
3690
+ _$insert2(_el$, error);
3691
+ _$effect2((_$p) => _$setProp2(_el$, "fg", api.theme.current.error, _$p));
3692
+ return _el$;
3693
+ })() : null,
3694
+ get busy() {
3695
+ return busy();
3696
+ },
3697
+ busyText: "Resolving repository",
3698
+ onConfirm: (value) => {
3699
+ if (finished || busy())
3700
+ return;
3701
+ setBusy(true);
3702
+ resolvePullRequestInput(value, {
3703
+ directory: options.directory,
3704
+ ...options.runner ? {
3705
+ runner: options.runner
3706
+ } : {},
3707
+ signal
3708
+ }).then((result) => {
3709
+ if (finished)
3710
+ return;
3711
+ setBusy(false);
3712
+ if (result.ok) {
3713
+ finish(result.value);
3714
+ return;
3715
+ }
3716
+ if (result.error.tag === "RepositoryResolutionCancelled") {
3717
+ finish(undefined);
3718
+ return;
3719
+ }
3720
+ setError(result.error.message);
3721
+ });
3722
+ },
3723
+ onCancel: () => finish(undefined)
3724
+ });
3725
+ }, () => finish(undefined, false));
3726
+ });
3727
+ }
3728
+ function selectPullRequest(api, title, attachments, signal) {
3729
+ return new Promise((resolve) => {
3730
+ let finished = false;
3731
+ const onAbort = () => finish(undefined);
3732
+ const finish = (value, clearDialog = true) => {
3733
+ if (finished)
3734
+ return;
3735
+ finished = true;
3736
+ signal.removeEventListener("abort", onAbort);
3737
+ if (clearDialog)
3738
+ api.ui.dialog.clear();
3739
+ resolve(value);
3740
+ };
3741
+ if (signal.aborted) {
3742
+ finish(undefined, false);
3743
+ return;
3744
+ }
3745
+ signal.addEventListener("abort", onAbort, {
3746
+ once: true
3747
+ });
3748
+ api.ui.dialog.setSize("medium");
3749
+ api.ui.dialog.replace(() => {
3750
+ const DialogSelect = api.ui.DialogSelect;
3751
+ return _$createComponent3(DialogSelect, {
3752
+ title,
3753
+ get options() {
3754
+ return attachments.map((attachment) => ({
3755
+ title: formatPullRequestRef(attachment.pullRequest),
3756
+ value: attachment.pullRequest,
3757
+ description: attachment.pullRequest.url
3758
+ }));
3759
+ },
3760
+ onSelect: (option) => finish(option.value)
3761
+ });
3762
+ }, () => finish(undefined, false));
3763
+ });
3764
+ }
3765
+ function showStateFailure(api, failure) {
3766
+ api.ui.toast({
3767
+ variant: "error",
3768
+ title: "Pull request tracker",
3769
+ message: failure.message
3770
+ });
3771
+ }
3772
+ function createPullRequestCommands(api, dependencies, refreshBus) {
3773
+ return [{
3774
+ name: "pr.attach",
3775
+ title: "Attach pull request",
3776
+ category: "Plugin",
3777
+ namespace: "palette",
3778
+ slashName: "pr-attach",
3779
+ async run() {
3780
+ const sessionID = currentSessionID(api);
3781
+ if (sessionID === undefined) {
3782
+ api.ui.toast({
3783
+ variant: "warning",
3784
+ title: "Pull request tracker",
3785
+ message: "Open a session first"
3786
+ });
3787
+ return;
3788
+ }
3789
+ const pullRequest = await promptForPullRequest(api, {
3790
+ directory: api.state.path.directory,
3791
+ ...dependencies.runner ? {
3792
+ runner: dependencies.runner
3793
+ } : {},
3794
+ signal: api.lifecycle.signal
3795
+ });
3796
+ if (pullRequest === undefined)
3797
+ return;
3798
+ const result = await attachPullRequest(dependencies, sessionID, pullRequest, {
3799
+ signal: api.lifecycle.signal
3800
+ });
3801
+ if (!result.ok) {
3802
+ showStateFailure(api, result.error);
3803
+ return;
3804
+ }
3805
+ const message = result.value === "added" ? `Attached ${formatPullRequestRef(pullRequest)}` : `${formatPullRequestRef(pullRequest)} is already attached`;
3806
+ api.ui.toast({
3807
+ variant: "success",
3808
+ title: "Pull request tracker",
3809
+ message
3810
+ });
3811
+ refreshBus.emit(sessionID);
3812
+ }
3813
+ }, {
3814
+ name: "pr.open",
3815
+ title: "Open pull request",
3816
+ category: "Plugin",
3817
+ namespace: "palette",
3818
+ slashName: "pr-open",
3819
+ async run() {
3820
+ const sessionID = currentSessionID(api);
3821
+ if (sessionID === undefined) {
3822
+ api.ui.toast({
3823
+ variant: "warning",
3824
+ title: "Pull request tracker",
3825
+ message: "Open a session first"
3826
+ });
3827
+ return;
3828
+ }
3829
+ const attachments = await dependencies.store.list(sessionID);
3830
+ if (!attachments.ok) {
3831
+ showStateFailure(api, attachments.error);
3832
+ return;
3833
+ }
3834
+ if (attachments.value.length === 0) {
3835
+ api.ui.toast({
3836
+ variant: "info",
3837
+ title: "Pull request tracker",
3838
+ message: "No pull requests are attached"
3839
+ });
3840
+ return;
3841
+ }
3842
+ const pullRequest = await selectPullRequest(api, "Open pull request", attachments.value, api.lifecycle.signal);
3843
+ if (pullRequest === undefined)
3844
+ return;
3845
+ const result = await openPullRequest(pullRequest, {
3846
+ ...dependencies.runner ? {
3847
+ runner: dependencies.runner
3848
+ } : {},
3849
+ signal: api.lifecycle.signal
3850
+ });
3851
+ if (!result.ok) {
3852
+ api.ui.toast({
3853
+ variant: "error",
3854
+ title: "Pull request tracker",
3855
+ message: result.error.message
3856
+ });
3857
+ }
3858
+ }
3859
+ }, {
3860
+ name: "pr.detach",
3861
+ title: "Detach pull request",
3862
+ category: "Plugin",
3863
+ namespace: "palette",
3864
+ slashName: "pr-detach",
3865
+ async run() {
3866
+ const sessionID = currentSessionID(api);
3867
+ if (sessionID === undefined) {
3868
+ api.ui.toast({
3869
+ variant: "warning",
3870
+ title: "Pull request tracker",
3871
+ message: "Open a session first"
3872
+ });
3873
+ return;
3874
+ }
3875
+ const attachments = await dependencies.store.list(sessionID);
3876
+ if (!attachments.ok) {
3877
+ showStateFailure(api, attachments.error);
3878
+ return;
3879
+ }
3880
+ if (attachments.value.length === 0) {
3881
+ api.ui.toast({
3882
+ variant: "info",
3883
+ title: "Pull request tracker",
3884
+ message: "No pull requests are attached"
3885
+ });
3886
+ return;
3887
+ }
3888
+ const pullRequest = await selectPullRequest(api, "Detach pull request", attachments.value, api.lifecycle.signal);
3889
+ if (pullRequest === undefined)
3890
+ return;
3891
+ const result = await dependencies.store.detach(sessionID, pullRequest);
3892
+ if (!result.ok) {
3893
+ showStateFailure(api, result.error);
3894
+ return;
3895
+ }
3896
+ const message = result.value === "removed" ? `Detached ${formatPullRequestRef(pullRequest)}` : `${formatPullRequestRef(pullRequest)} was not attached`;
3897
+ api.ui.toast({
3898
+ variant: "success",
3899
+ title: "Pull request tracker",
3900
+ message
3901
+ });
3902
+ refreshBus.emit(sessionID);
3903
+ }
3904
+ }, {
3905
+ name: "pr.sync",
3906
+ title: "Sync pull request status",
3907
+ category: "Plugin",
3908
+ namespace: "palette",
3909
+ slashName: "pr-sync",
3910
+ async run() {
3911
+ const sessionID = currentSessionID(api);
3912
+ if (sessionID === undefined) {
3913
+ api.ui.toast({
3914
+ variant: "warning",
3915
+ title: "Pull request tracker",
3916
+ message: "Open a session first"
3917
+ });
3918
+ return;
3919
+ }
3920
+ try {
3921
+ const result = await refreshBus.forceRefresh(sessionID);
3922
+ if (result === undefined) {
3923
+ api.ui.toast({
3924
+ variant: "warning",
3925
+ title: "Pull request tracker",
3926
+ message: "Pull request sidebar is not available"
3927
+ });
3928
+ return;
3929
+ }
3930
+ if (!result.ok) {
3931
+ api.ui.toast({
3932
+ variant: "error",
3933
+ title: "Pull request tracker",
3934
+ message: result.error.message
3935
+ });
3936
+ return;
3937
+ }
3938
+ switch (result.value) {
3939
+ case "refreshed":
3940
+ api.ui.toast({
3941
+ variant: "success",
3942
+ title: "Pull request tracker",
3943
+ message: "Pull request status synced"
3944
+ });
3945
+ return;
3946
+ case "no_attachments":
3947
+ api.ui.toast({
3948
+ variant: "info",
3949
+ title: "Pull request tracker",
3950
+ message: "No pull requests are attached"
3951
+ });
3952
+ return;
3953
+ case "stopped":
3954
+ api.ui.toast({
3955
+ variant: "error",
3956
+ title: "Pull request tracker",
3957
+ message: "Unable to refresh pull request status"
3958
+ });
3959
+ return;
3960
+ }
3961
+ } catch {
3962
+ api.ui.toast({
3963
+ variant: "error",
3964
+ title: "Pull request tracker",
3965
+ message: "Unable to refresh pull request status"
3966
+ });
3967
+ }
3968
+ }
3969
+ }];
3970
+ }
3971
+ function toneColor(theme, tone) {
3972
+ const colors = {
3973
+ green: theme.success,
3974
+ yellow: theme.warning,
3975
+ red: theme.error,
3976
+ purple: theme.secondary,
3977
+ gray: theme.textMuted
3978
+ };
3979
+ return colors[tone];
3980
+ }
3981
+ function PullRequestSidebar(props) {
3982
+ const [items, setItems] = createSignal2([]);
3983
+ const [failure, setFailure] = createSignal2();
3984
+ const [update, setUpdate] = createSignal2(props.updates.current());
3985
+ const [open, setOpen] = createSignal2(true);
3986
+ const collapsible = () => items().length > 2;
3987
+ const polling = startSessionPolling({
3988
+ sessionID: props.sessionID,
3989
+ store: props.dependencies.store,
3990
+ github: props.dependencies.github,
3991
+ publish: (value) => {
3992
+ setFailure(undefined);
3993
+ setItems(value);
3994
+ },
3995
+ onStateFailure: (error) => setFailure(error.message),
3996
+ onError: () => setFailure("Unable to refresh pull request status")
3997
+ });
3998
+ polling.start().catch(() => setFailure("Unable to refresh pull request status"));
3999
+ const unsubscribe = props.refreshBus.subscribe(props.sessionID, {
4000
+ refresh() {
4001
+ polling.refresh().catch(() => setFailure("Unable to refresh pull request status"));
4002
+ },
4003
+ async forceRefresh() {
4004
+ try {
4005
+ return await polling.forceRefresh();
4006
+ } catch (error) {
4007
+ setFailure("Unable to refresh pull request status");
4008
+ throw error;
4009
+ }
4010
+ }
4011
+ });
4012
+ const unsubscribeUpdate = props.updates.subscribe(setUpdate);
4013
+ const onAbort = () => polling.stop();
4014
+ props.api.lifecycle.signal.addEventListener("abort", onAbort, {
4015
+ once: true
4016
+ });
4017
+ onCleanup(() => {
4018
+ unsubscribe();
4019
+ unsubscribeUpdate();
4020
+ polling.stop();
4021
+ props.api.lifecycle.signal.removeEventListener("abort", onAbort);
4022
+ });
4023
+ return (() => {
4024
+ var _el$2 = _$createElement2("box"), _el$3 = _$createElement2("box"), _el$4 = _$createElement2("text"), _el$5 = _$createElement2("b");
4025
+ _$insertNode2(_el$2, _el$3);
4026
+ _$setProp2(_el$2, "flexDirection", "column");
4027
+ _$setProp2(_el$2, "gap", 1);
4028
+ _$insertNode2(_el$3, _el$4);
4029
+ _$setProp2(_el$3, "flexDirection", "row");
4030
+ _$setProp2(_el$3, "gap", 1);
4031
+ _$setProp2(_el$3, "onMouseDown", () => collapsible() && setOpen((value) => !value));
4032
+ _$insert2(_el$3, (() => {
4033
+ var _c$ = _$memo(() => !!collapsible());
4034
+ return () => _c$() ? (() => {
4035
+ var _el$7 = _$createElement2("text");
4036
+ _$insert2(_el$7, () => open() ? "\u25BC" : "\u25B6");
4037
+ _$effect2((_$p) => _$setProp2(_el$7, "fg", props.api.theme.current.text, _$p));
4038
+ return _el$7;
4039
+ })() : null;
4040
+ })(), _el$4);
4041
+ _$insertNode2(_el$4, _el$5);
4042
+ _$insertNode2(_el$5, _$createTextNode2(`Pull requests`));
4043
+ _$insert2(_el$2, (() => {
4044
+ var _c$2 = _$memo(() => !!(!collapsible() || open()));
4045
+ return () => _c$2() ? (() => {
4046
+ var _el$8 = _$createElement2("box");
4047
+ _$setProp2(_el$8, "flexDirection", "column");
4048
+ _$setProp2(_el$8, "gap", 1);
4049
+ _$insert2(_el$8, (() => {
4050
+ var _c$3 = _$memo(() => !!update());
4051
+ return () => _c$3() ? (() => {
4052
+ var _el$9 = _$createElement2("box"), _el$0 = _$createElement2("text"), _el$10 = _$createElement2("text");
4053
+ _$insertNode2(_el$9, _el$0);
4054
+ _$insertNode2(_el$9, _el$10);
4055
+ _$setProp2(_el$9, "flexDirection", "row");
4056
+ _$setProp2(_el$9, "onMouseUp", () => props.api.keymap.dispatchCommand("pr.tracker.plugin.update"));
4057
+ _$insertNode2(_el$0, _$createTextNode2(`\u2022 `));
4058
+ _$insert2(_el$10, () => updateStatusLabel(update()));
4059
+ _$effect2((_p$) => {
4060
+ var _v$ = props.api.theme.current.warning, _v$2 = props.api.theme.current.textMuted, _v$3 = TextAttributes.ITALIC;
4061
+ _v$ !== _p$.e && (_p$.e = _$setProp2(_el$0, "fg", _v$, _p$.e));
4062
+ _v$2 !== _p$.t && (_p$.t = _$setProp2(_el$10, "fg", _v$2, _p$.t));
4063
+ _v$3 !== _p$.a && (_p$.a = _$setProp2(_el$10, "attributes", _v$3, _p$.a));
4064
+ return _p$;
4065
+ }, {
4066
+ e: undefined,
4067
+ t: undefined,
4068
+ a: undefined
4069
+ });
4070
+ return _el$9;
4071
+ })() : null;
4072
+ })(), null);
4073
+ _$insert2(_el$8, (() => {
4074
+ var _c$4 = _$memo(() => !!failure());
4075
+ return () => _c$4() ? (() => {
4076
+ var _el$11 = _$createElement2("text");
4077
+ _$insert2(_el$11, failure);
4078
+ _$effect2((_$p) => _$setProp2(_el$11, "fg", props.api.theme.current.error, _$p));
4079
+ return _el$11;
4080
+ })() : null;
4081
+ })(), null);
4082
+ _$insert2(_el$8, (() => {
4083
+ var _c$5 = _$memo(() => !!(!failure() && items().length === 0));
4084
+ return () => _c$5() ? (() => {
4085
+ var _el$12 = _$createElement2("text");
4086
+ _$insertNode2(_el$12, _$createTextNode2(`No pull requests attached`));
4087
+ _$effect2((_$p) => _$setProp2(_el$12, "fg", props.api.theme.current.textMuted, _$p));
4088
+ return _el$12;
4089
+ })() : null;
4090
+ })(), null);
4091
+ _$insert2(_el$8, () => items().map((item) => {
4092
+ const appearance = statusAppearance(item.status);
4093
+ const attributes = appearance.strikethrough ? TextAttributes.STRIKETHROUGH : TextAttributes.NONE;
4094
+ const title = item.status.tag === "Available" ? item.status.title : "Title unavailable";
4095
+ return (() => {
4096
+ var _el$14 = _$createElement2("box"), _el$15 = _$createElement2("text"), _el$16 = _$createElement2("b"), _el$17 = _$createTextNode2(` `), _el$18 = _$createElement2("text");
4097
+ _$insertNode2(_el$14, _el$15);
4098
+ _$insertNode2(_el$14, _el$18);
4099
+ _$setProp2(_el$14, "flexDirection", "column");
4100
+ _$setProp2(_el$14, "onMouseUp", () => {
4101
+ openPullRequest(item.attachment.pullRequest, {
4102
+ ...props.dependencies.runner ? {
4103
+ runner: props.dependencies.runner
4104
+ } : {},
4105
+ signal: props.api.lifecycle.signal
4106
+ }).then((result) => {
4107
+ if (!result.ok) {
4108
+ props.api.ui.toast({
4109
+ variant: "error",
4110
+ title: "Pull request tracker",
4111
+ message: result.error.message
4112
+ });
4113
+ }
4114
+ }).catch(() => {
4115
+ props.api.ui.toast({
4116
+ variant: "error",
4117
+ title: "Pull request tracker",
4118
+ message: "Unable to open the pull request"
4119
+ });
4120
+ });
4121
+ });
4122
+ _$insertNode2(_el$15, _el$16);
4123
+ _$insertNode2(_el$15, _el$17);
4124
+ _$setProp2(_el$15, "attributes", attributes);
4125
+ _$insert2(_el$16, () => formatPullRequestRef(item.attachment.pullRequest));
4126
+ _$insert2(_el$15, () => appearance.label, null);
4127
+ _$setProp2(_el$18, "attributes", attributes);
4128
+ _$insert2(_el$18, title);
4129
+ _$effect2((_p$) => {
4130
+ var _v$4 = toneColor(props.api.theme.current, appearance.tone), _v$5 = props.api.theme.current.textMuted;
4131
+ _v$4 !== _p$.e && (_p$.e = _$setProp2(_el$15, "fg", _v$4, _p$.e));
4132
+ _v$5 !== _p$.t && (_p$.t = _$setProp2(_el$18, "fg", _v$5, _p$.t));
4133
+ return _p$;
4134
+ }, {
4135
+ e: undefined,
4136
+ t: undefined
4137
+ });
4138
+ return _el$14;
4139
+ })();
4140
+ }), null);
4141
+ return _el$8;
4142
+ })() : null;
4143
+ })(), null);
4144
+ _$effect2((_$p) => _$setProp2(_el$4, "fg", props.api.theme.current.text, _$p));
4145
+ return _el$2;
4146
+ })();
4147
+ }
4148
+
4149
+ // src/state.ts
4150
+ var import_proper_lockfile = __toESM(require_proper_lockfile(), 1);
4151
+ import { createHash, randomUUID } from "crypto";
4152
+ import { mkdir, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
4153
+ import { homedir } from "os";
4154
+ import { join as join3 } from "path";
4155
+ var maximumPullRequestsPerSession = 20;
4156
+ var invalidStateFile = {
4157
+ ok: false,
4158
+ error: {
4159
+ tag: "InvalidStateFile",
4160
+ message: "The session pull request state file is invalid"
4161
+ }
4162
+ };
4163
+ var lockStaleMilliseconds = 1e4;
4164
+ var lockUpdateMilliseconds = 2000;
4165
+ function stateUnavailable(operation, message, cause) {
4166
+ return { tag: "StateUnavailable", operation, message, cause };
4167
+ }
4168
+ function isRecord3(value) {
4169
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4170
+ }
4171
+ function hasExactKeys(value, keys) {
4172
+ const actual = Object.keys(value);
4173
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
4174
+ }
4175
+ function parseState(input) {
4176
+ if (!isRecord3(input) || !hasExactKeys(input, ["version", "pullRequests"]))
4177
+ return invalidStateFile;
4178
+ if (input.version !== 1 || !Array.isArray(input.pullRequests))
4179
+ return invalidStateFile;
4180
+ if (input.pullRequests.length > maximumPullRequestsPerSession)
4181
+ return invalidStateFile;
4182
+ const attachments = [];
4183
+ const seen = new Set;
4184
+ for (const item of input.pullRequests) {
4185
+ if (!isRecord3(item) || !hasExactKeys(item, ["url", "attachedAt"]))
4186
+ return invalidStateFile;
4187
+ if (typeof item.url !== "string" || typeof item.attachedAt !== "string")
4188
+ return invalidStateFile;
4189
+ const parsed = parsePullRequestUrl(item.url);
4190
+ if (!parsed.ok || parsed.value.url !== item.url || seen.has(item.url))
4191
+ return invalidStateFile;
4192
+ const attachedAt = new Date(item.attachedAt);
4193
+ if (Number.isNaN(attachedAt.valueOf()) || attachedAt.toISOString() !== item.attachedAt)
4194
+ return invalidStateFile;
4195
+ seen.add(item.url);
4196
+ attachments.push({ pullRequest: parsed.value, attachedAt: item.attachedAt });
4197
+ }
4198
+ return { ok: true, value: attachments };
4199
+ }
4200
+ function isMissingFile2(cause) {
4201
+ return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
4202
+ }
4203
+ function fileName(sessionID) {
4204
+ return `${createHash("sha256").update(sessionID).digest("hex")}.json`;
4205
+ }
4206
+ function defaultStateDirectory(environment = process.env, home = homedir()) {
4207
+ const dataHome = environment.XDG_DATA_HOME || join3(home, ".local", "share");
4208
+ return join3(dataHome, "opencode", "opencode-pr-tracker");
4209
+ }
4210
+ function createStateStore(options = {}) {
4211
+ const directory = options.directory ?? defaultStateDirectory();
4212
+ const now = options.now ?? (() => new Date);
4213
+ const lockStateFile = options.lock ?? import_proper_lockfile.lock;
4214
+ const attachTails = new Map;
4215
+ async function enqueueAttach(sessionID, operation) {
4216
+ const previous = attachTails.get(sessionID) ?? Promise.resolve();
4217
+ let release;
4218
+ const current = new Promise((resolve) => {
4219
+ release = resolve;
4220
+ });
4221
+ attachTails.set(sessionID, current);
4222
+ await previous;
4223
+ try {
4224
+ return await operation();
4225
+ } finally {
4226
+ release();
4227
+ if (attachTails.get(sessionID) === current)
4228
+ attachTails.delete(sessionID);
4229
+ }
4230
+ }
4231
+ async function acquireLock(sessionID) {
4232
+ const stateFile = join3(directory, fileName(sessionID));
4233
+ let compromised;
4234
+ try {
4235
+ await mkdir(directory, { recursive: true });
4236
+ const release = await lockStateFile(stateFile, {
4237
+ realpath: false,
4238
+ stale: lockStaleMilliseconds,
4239
+ update: lockUpdateMilliseconds,
4240
+ retries: { retries: 50, factor: 1, minTimeout: 10, maxTimeout: 100 },
4241
+ onCompromised: (error) => {
4242
+ compromised = error;
4243
+ }
4244
+ });
4245
+ return { ok: true, value: { release, compromised: () => compromised } };
4246
+ } catch (cause) {
4247
+ return {
4248
+ ok: false,
4249
+ error: stateUnavailable("write", "Unable to lock the session pull request state", cause)
4250
+ };
4251
+ }
4252
+ }
4253
+ async function withLock(sessionID, operation) {
4254
+ const lock = await acquireLock(sessionID);
4255
+ if (!lock.ok)
4256
+ return lock;
4257
+ let result;
4258
+ try {
4259
+ result = await operation();
4260
+ } catch (cause) {
4261
+ await lock.value.release().catch(() => {
4262
+ return;
4263
+ });
4264
+ throw cause;
4265
+ }
4266
+ try {
4267
+ await lock.value.release();
4268
+ } catch (cause) {
4269
+ return {
4270
+ ok: false,
4271
+ error: stateUnavailable("write", "Unable to unlock the session pull request state", cause)
4272
+ };
4273
+ }
4274
+ const compromise = lock.value.compromised();
4275
+ if (compromise !== undefined) {
4276
+ return {
4277
+ ok: false,
4278
+ error: stateUnavailable("write", "The session pull request state lock was compromised", compromise)
4279
+ };
4280
+ }
4281
+ return result;
4282
+ }
4283
+ async function readExisting(sessionID) {
4284
+ const path = join3(directory, fileName(sessionID));
4285
+ let content;
4286
+ try {
4287
+ content = await readFile2(path, "utf8");
4288
+ } catch (cause) {
4289
+ if (isMissingFile2(cause))
4290
+ return { ok: true, value: undefined };
4291
+ return {
4292
+ ok: false,
4293
+ error: stateUnavailable("read", "Unable to read the session pull request state", cause)
4294
+ };
4295
+ }
4296
+ let decoded;
4297
+ try {
4298
+ decoded = JSON.parse(content);
4299
+ } catch {
4300
+ return invalidStateFile;
4301
+ }
4302
+ return parseState(decoded);
4303
+ }
4304
+ async function read(sessionID) {
4305
+ const result = await readExisting(sessionID);
4306
+ if (!result.ok)
4307
+ return result;
4308
+ return { ok: true, value: result.value ?? [] };
4309
+ }
4310
+ async function write(sessionID, attachments) {
4311
+ const destination = join3(directory, fileName(sessionID));
4312
+ const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
4313
+ const state = {
4314
+ version: 1,
4315
+ pullRequests: attachments.map((attachment) => ({
4316
+ url: attachment.pullRequest.url,
4317
+ attachedAt: attachment.attachedAt
4318
+ }))
2549
4319
  };
2550
- api.ui.dialog.setSize("medium");
2551
- api.ui.dialog.replace(() => {
2552
- const DialogSelect = api.ui.DialogSelect;
2553
- return _$createComponent(DialogSelect, {
2554
- title,
2555
- get options() {
2556
- return attachments.map((attachment) => ({
2557
- title: formatPullRequestRef(attachment.pullRequest),
2558
- value: attachment.pullRequest,
2559
- description: attachment.pullRequest.url
2560
- }));
2561
- },
2562
- onSelect: (option) => finish(option.value)
4320
+ try {
4321
+ await mkdir(directory, { recursive: true });
4322
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}
4323
+ `, { mode: 384 });
4324
+ await rename(temporary, destination);
4325
+ return { ok: true, value: undefined };
4326
+ } catch (cause) {
4327
+ await rm(temporary, { force: true }).catch(() => {
4328
+ return;
2563
4329
  });
2564
- }, () => {
2565
- if (!finished)
2566
- resolve(undefined);
2567
- });
2568
- });
2569
- }
2570
- function showStateFailure(api, failure) {
2571
- api.ui.toast({
2572
- variant: "error",
2573
- title: "Pull request tracker",
2574
- message: failure.message
2575
- });
2576
- }
2577
- function toneColor(theme, tone) {
2578
- const colors = {
2579
- green: theme.success,
2580
- yellow: theme.warning,
2581
- red: theme.error,
2582
- purple: theme.secondary,
2583
- gray: theme.textMuted
2584
- };
2585
- return colors[tone];
2586
- }
2587
- function PullRequestSidebar(props) {
2588
- const [items, setItems] = createSignal([]);
2589
- const [failure, setFailure] = createSignal();
2590
- const polling = startSessionPolling({
2591
- sessionID: props.sessionID,
2592
- store: props.dependencies.store,
2593
- github: props.dependencies.github,
2594
- publish: (value) => {
2595
- setFailure(undefined);
2596
- setItems(value);
2597
- },
2598
- onStateFailure: (error) => setFailure(error.message),
2599
- onError: () => setFailure("Unable to refresh pull request status")
2600
- });
2601
- polling.start().catch(() => setFailure("Unable to refresh pull request status"));
2602
- const unsubscribe = props.refreshBus.subscribe(props.sessionID, () => {
2603
- polling.refresh().catch(() => setFailure("Unable to refresh pull request status"));
2604
- });
2605
- const onAbort = () => polling.stop();
2606
- props.api.lifecycle.signal.addEventListener("abort", onAbort, {
2607
- once: true
2608
- });
2609
- onCleanup(() => {
2610
- unsubscribe();
2611
- polling.stop();
2612
- props.api.lifecycle.signal.removeEventListener("abort", onAbort);
2613
- });
2614
- return (() => {
2615
- var _el$2 = _$createElement("box"), _el$3 = _$createElement("text"), _el$4 = _$createElement("b");
2616
- _$insertNode(_el$2, _el$3);
2617
- _$setProp(_el$2, "flexDirection", "column");
2618
- _$setProp(_el$2, "gap", 1);
2619
- _$insertNode(_el$3, _el$4);
2620
- _$insertNode(_el$4, _$createTextNode(`Pull requests`));
2621
- _$insert(_el$2, (() => {
2622
- var _c$ = _$memo(() => !!failure());
2623
- return () => _c$() ? (() => {
2624
- var _el$6 = _$createElement("text");
2625
- _$insert(_el$6, failure);
2626
- _$effect((_$p) => _$setProp(_el$6, "fg", props.api.theme.current.error, _$p));
2627
- return _el$6;
2628
- })() : null;
2629
- })(), null);
2630
- _$insert(_el$2, (() => {
2631
- var _c$2 = _$memo(() => !!(!failure() && items().length === 0));
2632
- return () => _c$2() ? (() => {
2633
- var _el$7 = _$createElement("text");
2634
- _$insertNode(_el$7, _$createTextNode(`No pull requests attached`));
2635
- _$effect((_$p) => _$setProp(_el$7, "fg", props.api.theme.current.textMuted, _$p));
2636
- return _el$7;
2637
- })() : null;
2638
- })(), null);
2639
- _$insert(_el$2, () => items().map((item) => {
2640
- const appearance = statusAppearance(item.status);
2641
- const attributes = appearance.strikethrough ? TextAttributes.STRIKETHROUGH : TextAttributes.NONE;
2642
- const title = item.status.tag === "Available" ? item.status.title : "Title unavailable";
2643
- return (() => {
2644
- var _el$9 = _$createElement("box"), _el$0 = _$createElement("text"), _el$1 = _$createElement("b"), _el$10 = _$createTextNode(` `), _el$11 = _$createElement("text");
2645
- _$insertNode(_el$9, _el$0);
2646
- _$insertNode(_el$9, _el$11);
2647
- _$setProp(_el$9, "flexDirection", "column");
2648
- _$setProp(_el$9, "onMouseUp", () => {
2649
- openPullRequest(item.attachment.pullRequest, {
2650
- ...props.dependencies.runner ? {
2651
- runner: props.dependencies.runner
2652
- } : {},
2653
- signal: props.api.lifecycle.signal
2654
- }).then((result) => {
2655
- if (!result.ok) {
2656
- props.api.ui.toast({
2657
- variant: "error",
2658
- title: "Pull request tracker",
2659
- message: result.error.message
2660
- });
2661
- }
2662
- }).catch(() => {
2663
- props.api.ui.toast({
2664
- variant: "error",
2665
- title: "Pull request tracker",
2666
- message: "Unable to open the pull request"
2667
- });
2668
- });
2669
- });
2670
- _$insertNode(_el$0, _el$1);
2671
- _$insertNode(_el$0, _el$10);
2672
- _$setProp(_el$0, "attributes", attributes);
2673
- _$insert(_el$1, () => formatPullRequestRef(item.attachment.pullRequest));
2674
- _$insert(_el$0, () => appearance.label, null);
2675
- _$setProp(_el$11, "attributes", attributes);
2676
- _$insert(_el$11, title);
2677
- _$effect((_p$) => {
2678
- var _v$ = toneColor(props.api.theme.current, appearance.tone), _v$2 = props.api.theme.current.textMuted;
2679
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$0, "fg", _v$, _p$.e));
2680
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$11, "fg", _v$2, _p$.t));
2681
- return _p$;
2682
- }, {
2683
- e: undefined,
2684
- t: undefined
2685
- });
2686
- return _el$9;
2687
- })();
2688
- }), null);
2689
- _$effect((_$p) => _$setProp(_el$3, "fg", props.api.theme.current.text, _$p));
2690
- return _el$2;
2691
- })();
2692
- }
2693
- function registerTui(api, dependencies) {
2694
- const refreshBus = createRefreshBus();
2695
- api.event.on("session.updated", (event) => refreshBus.emit(event.properties.sessionID));
2696
- api.event.on("message.updated", (event) => refreshBus.emit(event.properties.sessionID));
2697
- api.event.on("message.part.updated", (event) => refreshBus.emit(event.properties.sessionID));
2698
- const disposeCommands = api.keymap.registerLayer({
2699
- commands: [{
2700
- name: "pr.attach",
2701
- title: "Attach pull request",
2702
- category: "Plugin",
2703
- namespace: "palette",
2704
- slashName: "pr-attach",
2705
- async run() {
2706
- const sessionID = currentSessionID(api);
2707
- if (sessionID === undefined) {
2708
- api.ui.toast({
2709
- variant: "warning",
2710
- title: "Pull request tracker",
2711
- message: "Open a session first"
2712
- });
2713
- return;
2714
- }
2715
- const pullRequest = await promptForPullRequest(api, {
2716
- directory: api.state.path.directory,
2717
- ...dependencies.runner ? {
2718
- runner: dependencies.runner
2719
- } : {},
2720
- signal: api.lifecycle.signal
2721
- });
2722
- if (pullRequest === undefined)
2723
- return;
2724
- const result = await dependencies.store.attach(sessionID, pullRequest);
2725
- if (!result.ok) {
2726
- showStateFailure(api, result.error);
2727
- return;
2728
- }
2729
- const message = result.value === "added" ? `Attached ${formatPullRequestRef(pullRequest)}` : `${formatPullRequestRef(pullRequest)} is already attached`;
2730
- api.ui.toast({
2731
- variant: "success",
2732
- title: "Pull request tracker",
2733
- message
2734
- });
2735
- refreshBus.emit(sessionID);
2736
- }
2737
- }, {
2738
- name: "pr.open",
2739
- title: "Open pull request",
2740
- category: "Plugin",
2741
- namespace: "palette",
2742
- slashName: "pr-open",
2743
- async run() {
2744
- const sessionID = currentSessionID(api);
2745
- if (sessionID === undefined) {
2746
- api.ui.toast({
2747
- variant: "warning",
2748
- title: "Pull request tracker",
2749
- message: "Open a session first"
2750
- });
2751
- return;
2752
- }
2753
- const attachments = await dependencies.store.list(sessionID);
2754
- if (!attachments.ok) {
2755
- showStateFailure(api, attachments.error);
2756
- return;
2757
- }
2758
- if (attachments.value.length === 0) {
2759
- api.ui.toast({
2760
- variant: "info",
2761
- title: "Pull request tracker",
2762
- message: "No pull requests are attached"
2763
- });
2764
- return;
4330
+ return {
4331
+ ok: false,
4332
+ error: stateUnavailable("write", "Unable to write the session pull request state", cause)
4333
+ };
4334
+ }
4335
+ }
4336
+ async function attach(sessionID, pullRequest, attachOptions = {}) {
4337
+ return enqueueAttach(sessionID, async () => {
4338
+ if (attachOptions.validate !== undefined) {
4339
+ const current = await read(sessionID);
4340
+ if (!current.ok)
4341
+ return current;
4342
+ if (current.value.some((attachment) => attachment.pullRequest.url === pullRequest.url)) {
4343
+ return { ok: true, value: "already_attached" };
2765
4344
  }
2766
- const pullRequest = await selectPullRequest(api, "Open pull request", attachments.value);
2767
- if (pullRequest === undefined)
2768
- return;
2769
- const result = await openPullRequest(pullRequest, {
2770
- ...dependencies.runner ? {
2771
- runner: dependencies.runner
2772
- } : {},
2773
- signal: api.lifecycle.signal
2774
- });
2775
- if (!result.ok) {
2776
- api.ui.toast({
2777
- variant: "error",
2778
- title: "Pull request tracker",
2779
- message: result.error.message
2780
- });
4345
+ if (current.value.length >= maximumPullRequestsPerSession) {
4346
+ return {
4347
+ ok: false,
4348
+ error: {
4349
+ tag: "AttachmentLimitReached",
4350
+ limit: maximumPullRequestsPerSession,
4351
+ message: "A session can track at most 20 pull requests"
4352
+ }
4353
+ };
2781
4354
  }
4355
+ const validation = await attachOptions.validate();
4356
+ if (!validation.ok)
4357
+ return validation;
2782
4358
  }
2783
- }, {
2784
- name: "pr.detach",
2785
- title: "Detach pull request",
2786
- category: "Plugin",
2787
- namespace: "palette",
2788
- slashName: "pr-detach",
2789
- async run() {
2790
- const sessionID = currentSessionID(api);
2791
- if (sessionID === undefined) {
2792
- api.ui.toast({
2793
- variant: "warning",
2794
- title: "Pull request tracker",
2795
- message: "Open a session first"
2796
- });
2797
- return;
4359
+ return withLock(sessionID, async () => {
4360
+ const current = await read(sessionID);
4361
+ if (!current.ok)
4362
+ return current;
4363
+ if (current.value.some((attachment) => attachment.pullRequest.url === pullRequest.url)) {
4364
+ return { ok: true, value: "already_attached" };
2798
4365
  }
2799
- const attachments = await dependencies.store.list(sessionID);
2800
- if (!attachments.ok) {
2801
- showStateFailure(api, attachments.error);
2802
- return;
4366
+ if (current.value.length >= maximumPullRequestsPerSession) {
4367
+ return {
4368
+ ok: false,
4369
+ error: {
4370
+ tag: "AttachmentLimitReached",
4371
+ limit: maximumPullRequestsPerSession,
4372
+ message: "A session can track at most 20 pull requests"
4373
+ }
4374
+ };
2803
4375
  }
2804
- if (attachments.value.length === 0) {
2805
- api.ui.toast({
2806
- variant: "info",
2807
- title: "Pull request tracker",
2808
- message: "No pull requests are attached"
2809
- });
2810
- return;
4376
+ const written = await write(sessionID, [...current.value, { pullRequest, attachedAt: now().toISOString() }]);
4377
+ if (!written.ok)
4378
+ return written;
4379
+ return { ok: true, value: "added" };
4380
+ });
4381
+ });
4382
+ }
4383
+ return {
4384
+ list: read,
4385
+ attach,
4386
+ async detach(sessionID, pullRequest) {
4387
+ return withLock(sessionID, async () => {
4388
+ const current = await read(sessionID);
4389
+ if (!current.ok)
4390
+ return current;
4391
+ const next = current.value.filter((attachment) => attachment.pullRequest.url !== pullRequest.url);
4392
+ if (next.length === current.value.length)
4393
+ return { ok: true, value: "absent" };
4394
+ const written = await write(sessionID, next);
4395
+ if (!written.ok)
4396
+ return written;
4397
+ return { ok: true, value: "removed" };
4398
+ });
4399
+ },
4400
+ async detachByNumber(sessionID, number) {
4401
+ return withLock(sessionID, async () => {
4402
+ const current = await read(sessionID);
4403
+ if (!current.ok)
4404
+ return current;
4405
+ const matches = current.value.filter((attachment) => attachment.pullRequest.number === number);
4406
+ if (matches.length === 0)
4407
+ return { ok: true, value: { tag: "absent" } };
4408
+ if (matches.length > 1) {
4409
+ return {
4410
+ ok: true,
4411
+ value: { tag: "ambiguous", pullRequests: matches.map((attachment) => attachment.pullRequest) }
4412
+ };
2811
4413
  }
2812
- const pullRequest = await selectPullRequest(api, "Detach pull request", attachments.value);
2813
- if (pullRequest === undefined)
2814
- return;
2815
- const result = await dependencies.store.detach(sessionID, pullRequest);
2816
- if (!result.ok) {
2817
- showStateFailure(api, result.error);
2818
- return;
4414
+ const match = matches[0];
4415
+ if (match === undefined)
4416
+ return { ok: true, value: { tag: "absent" } };
4417
+ const next = current.value.filter((attachment) => attachment.pullRequest.url !== match.pullRequest.url);
4418
+ const written = await write(sessionID, next);
4419
+ if (!written.ok)
4420
+ return written;
4421
+ return { ok: true, value: { tag: "removed", pullRequest: match.pullRequest } };
4422
+ });
4423
+ },
4424
+ async removeSession(sessionID) {
4425
+ return withLock(sessionID, async () => {
4426
+ const current = await readExisting(sessionID);
4427
+ if (!current.ok)
4428
+ return current;
4429
+ if (current.value === undefined)
4430
+ return { ok: true, value: "absent" };
4431
+ try {
4432
+ await rm(join3(directory, fileName(sessionID)), { force: true });
4433
+ return { ok: true, value: "removed" };
4434
+ } catch (cause) {
4435
+ return {
4436
+ ok: false,
4437
+ error: stateUnavailable("write", "Unable to remove the session pull request state", cause)
4438
+ };
2819
4439
  }
2820
- const message = result.value === "removed" ? `Detached ${formatPullRequestRef(pullRequest)}` : `${formatPullRequestRef(pullRequest)} was not attached`;
2821
- api.ui.toast({
2822
- variant: "success",
2823
- title: "Pull request tracker",
2824
- message
2825
- });
2826
- refreshBus.emit(sessionID);
2827
- }
2828
- }],
4440
+ });
4441
+ }
4442
+ };
4443
+ }
4444
+
4445
+ // src/tui.tsx
4446
+ function registerTui(api, dependencies, release) {
4447
+ const refreshBus = dependencies.refreshBus ?? createRefreshBus();
4448
+ const updates = createPluginUpdateController(api, dependencies, release);
4449
+ const disposeEvents = [api.event.on("session.updated", (event) => refreshBus.emit(event.properties.sessionID)), api.event.on("message.updated", (event) => refreshBus.emit(event.properties.sessionID)), api.event.on("message.part.updated", (event) => refreshBus.emit(event.properties.sessionID))];
4450
+ const disposeCommands = api.keymap.registerLayer({
4451
+ commands: [...createPullRequestCommands(api, dependencies, refreshBus), updates.command, createFeedbackCommand(api, dependencies, release)],
2829
4452
  bindings: []
2830
4453
  });
2831
- api.lifecycle.onDispose(disposeCommands);
4454
+ api.lifecycle.onDispose(async () => {
4455
+ disposeCommands();
4456
+ for (const disposeEvent of disposeEvents)
4457
+ disposeEvent();
4458
+ await updates.startup;
4459
+ });
2832
4460
  api.slots.register({
2833
4461
  order: 250,
2834
4462
  slots: {
2835
4463
  sidebar_content(_context, value) {
2836
- return _$createComponent(PullRequestSidebar, {
4464
+ return _$createComponent4(PullRequestSidebar, {
2837
4465
  api,
2838
4466
  get sessionID() {
2839
4467
  return value.session_id;
2840
4468
  },
2841
4469
  dependencies,
2842
- refreshBus
4470
+ refreshBus,
4471
+ updates
2843
4472
  });
2844
4473
  }
2845
4474
  }
@@ -2847,22 +4476,23 @@ function registerTui(api, dependencies) {
2847
4476
  }
2848
4477
  var plugin = {
2849
4478
  id: "opencode-pr-tracker",
2850
- async tui(api, options) {
4479
+ async tui(api, options, meta) {
2851
4480
  if (options?.enabled === false)
2852
4481
  return;
2853
4482
  registerTui(api, {
2854
4483
  store: createStateStore(),
2855
4484
  github: createGitHubClient()
2856
- });
4485
+ }, meta);
2857
4486
  }
2858
4487
  };
2859
4488
  var tui_default = plugin;
2860
4489
  export {
4490
+ updateStatusLabel,
2861
4491
  startSessionPolling,
2862
4492
  registerTui,
2863
4493
  openPullRequest,
2864
4494
  tui_default as default,
2865
- attachPullRequest
4495
+ attachPullRequest2 as attachPullRequest
2866
4496
  };
2867
4497
 
2868
- //# debugId=73D7339550CD79BA64756E2164756E21
4498
+ //# debugId=9AE11FCB9BB4D09764756E2164756E21