@hcrosse/opencode-pr-tracker 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,48 +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))
1586
1590
  return invalidPullRequestUrl;
1587
1591
  if (input.includes("\\"))
1588
1592
  return invalidPullRequestUrl;
1589
- if (!input.startsWith("https://"))
1593
+ const candidate = input.slice(0, schemeLessPrefix.length).toLowerCase() === schemeLessPrefix ? `https://${input}` : input;
1594
+ if (!candidate.startsWith("https://"))
1590
1595
  return invalidPullRequestUrl;
1591
- const authorityEnd = input.indexOf("/", "https://".length);
1596
+ const authorityEnd = candidate.indexOf("/", "https://".length);
1592
1597
  if (authorityEnd === -1)
1593
1598
  return invalidPullRequestUrl;
1594
- if (input.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1599
+ if (candidate.slice("https://".length, authorityEnd).toLowerCase() !== "github.com") {
1595
1600
  return invalidPullRequestUrl;
1596
1601
  }
1597
- const rawPath = input.slice(authorityEnd).split(/[?#]/, 1).join("");
1602
+ const rawPath = candidate.slice(authorityEnd).split(/[?#]/, 1).join("");
1598
1603
  for (const segment of rawPath.split("/")) {
1599
1604
  let decoded;
1600
1605
  try {
@@ -1607,7 +1612,7 @@ function parsePullRequestUrl(input) {
1607
1612
  }
1608
1613
  let parsed;
1609
1614
  try {
1610
- parsed = new URL(input);
1615
+ parsed = new URL(candidate);
1611
1616
  } catch {
1612
1617
  return invalidPullRequestUrl;
1613
1618
  }
@@ -1645,6 +1650,13 @@ var invalidGitHubResponse = {
1645
1650
  message: "GitHub returned an invalid pull request response"
1646
1651
  }
1647
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
+ };
1648
1660
  var githubBatchLimitExceeded = {
1649
1661
  ok: false,
1650
1662
  error: {
@@ -1653,15 +1665,13 @@ var githubBatchLimitExceeded = {
1653
1665
  message: "GitHub batch cannot contain more than 20 pull requests"
1654
1666
  }
1655
1667
  };
1656
- var checkRunPending = new Set(["QUEUED", "IN_PROGRESS", "WAITING", "PENDING"]);
1657
- var checkRunPassed = new Set(["SUCCESS"]);
1658
- var checkRunFailed = new Set(["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"]);
1659
- var checkRunIgnored = new Set(["NEUTRAL", "SKIPPED"]);
1660
- var statusContextPending = new Set(["EXPECTED", "PENDING"]);
1661
- var statusContextPassed = new Set(["SUCCESS"]);
1662
- var statusContextFailed = new Set(["ERROR", "FAILURE"]);
1663
1668
  var maximumPullRequestsPerBatch = 20;
1664
- 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} } } } } }`;
1665
1675
  function isRecord(value) {
1666
1676
  return value !== null && typeof value === "object" && !Array.isArray(value);
1667
1677
  }
@@ -1677,67 +1687,297 @@ function parseProcessExecutionFailed(value) {
1677
1687
  cause: value.cause
1678
1688
  };
1679
1689
  }
1680
- function classifyCountState(state, states) {
1681
- if (states.failed.has(state))
1682
- return "failed";
1683
- if (states.pending.has(state))
1684
- return "pending";
1685
- if (states.passed.has(state))
1686
- return "passed";
1687
- if (states.ignored?.has(state))
1688
- return "ignored";
1689
- 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
+ };
1690
1721
  }
1691
- function aggregateCounts(input, expectedTotal, states) {
1692
- if (!Array.isArray(input) || !Number.isInteger(expectedTotal) || Number(expectedTotal) < 0) {
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
+ }
1777
+ }
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) {
1693
1784
  return invalidGitHubResponse;
1694
1785
  }
1695
- const buckets = new Set;
1696
- const seenStates = new Set;
1697
- let total = 0;
1698
- for (const item of input) {
1699
- 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)) {
1700
1816
  return invalidGitHubResponse;
1701
1817
  }
1702
- const bucket = classifyCountState(item.state, states);
1703
- 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) {
1704
1823
  return invalidGitHubResponse;
1705
- seenStates.add(item.state);
1706
- total += Number(item.count);
1707
- if (bucket !== undefined && Number(item.count) > 0)
1708
- buckets.add(bucket);
1824
+ }
1825
+ workflowRun = { event, workflowId, runNumber: Number(runNumber), runAttempt: Number(runAttempt) };
1709
1826
  }
1710
- 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
+ };
1711
1831
  }
1712
- function parseStatusCheckRollup(input) {
1713
- if (input === null)
1714
- return { ok: true, value: "none" };
1715
- 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") {
1716
1834
  return invalidGitHubResponse;
1717
- const contexts = input.contexts;
1718
- const checkRuns = aggregateCounts(contexts.checkRunCountsByState, contexts.checkRunCount, {
1719
- passed: checkRunPassed,
1720
- pending: checkRunPending,
1721
- failed: checkRunFailed,
1722
- ignored: checkRunIgnored
1723
- });
1724
- if (!checkRuns.ok)
1725
- return checkRuns;
1726
- const statusContexts = aggregateCounts(contexts.statusContextCountsByState, contexts.statusContextCount, {
1727
- passed: statusContextPassed,
1728
- pending: statusContextPending,
1729
- failed: statusContextFailed
1730
- });
1731
- if (!statusContexts.ok)
1732
- return statusContexts;
1733
- 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
+ }
1734
1967
  if (buckets.has("failed"))
1735
- return { ok: true, value: "failed" };
1968
+ return "failed";
1736
1969
  if (buckets.has("pending"))
1737
- return { ok: true, value: "pending" };
1970
+ return "pending";
1738
1971
  if (buckets.has("passed"))
1739
- return { ok: true, value: "passed" };
1740
- 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);
1741
1981
  }
1742
1982
  function samePullRequest(left, right) {
1743
1983
  return left.number === right.number && left.owner.toLowerCase() === right.owner.toLowerCase() && left.repository.toLowerCase() === right.repository.toLowerCase();
@@ -1754,7 +1994,118 @@ function parseMergeability(input) {
1754
1994
  return invalidGitHubResponse;
1755
1995
  }
1756
1996
  }
1757
- 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) {
1758
2109
  if (!isRecord(input) || input.__typename !== "PullRequest" || typeof input.title !== "string" || input.title.trim() === "") {
1759
2110
  return invalidGitHubResponse;
1760
2111
  }
@@ -1775,17 +2126,34 @@ function parseResponse(input, pullRequest) {
1775
2126
  const responseUrl = parsePullRequestUrl(input.url);
1776
2127
  if (!responseUrl.ok || !samePullRequest(responseUrl.value, pullRequest))
1777
2128
  return invalidGitHubResponse;
1778
- const ci = parseStatusCheckRollup(input.statusCheckRollup);
1779
- if (!ci.ok)
1780
- return ci;
1781
2129
  const mergeability = parseMergeability(input.mergeable);
1782
2130
  if (!mergeability.ok)
1783
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) {
1784
2144
  let state;
1785
- switch (input.state) {
1786
- case "OPEN":
1787
- 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 };
1788
2155
  break;
2156
+ }
1789
2157
  case "MERGED":
1790
2158
  state = { tag: "Merged" };
1791
2159
  break;
@@ -1793,19 +2161,33 @@ function parseResponse(input, pullRequest) {
1793
2161
  state = { tag: "Closed" };
1794
2162
  break;
1795
2163
  default:
1796
- return casesHandled(input.state);
2164
+ return casesHandled(metadata.state);
1797
2165
  }
1798
2166
  return {
1799
2167
  ok: true,
1800
2168
  value: {
1801
2169
  tag: "Available",
1802
2170
  pullRequest,
1803
- title: input.title,
2171
+ title: metadata.title,
1804
2172
  state,
1805
2173
  stale: false
1806
2174
  }
1807
2175
  };
1808
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
+ }
1809
2191
  function createBatchQuery(size) {
1810
2192
  const variables = Array.from({ length: size }, (_, index) => `$url${index}: URI!`).join(", ");
1811
2193
  const fields = Array.from({ length: size }, (_, index) => `pr${index}: resource(url: $url${index}) { ${pullRequestSelection} }`).join(" ");
@@ -1840,9 +2222,22 @@ function parseBatchResponse(input, pullRequests) {
1840
2222
  return errorAliases;
1841
2223
  return {
1842
2224
  ok: true,
1843
- 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))
1844
2226
  };
1845
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
+ }
1846
2241
  function isCancellation(cause, signal) {
1847
2242
  if (signal?.aborted)
1848
2243
  return true;
@@ -1871,6 +2266,94 @@ function processFailureStdout(cause) {
1871
2266
  return;
1872
2267
  return cause.stdout;
1873
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
+ }
1874
2357
  var execFileRunner = (file, args, options) => new Promise((resolve, reject) => {
1875
2358
  execFile(file, [...args], {
1876
2359
  encoding: "utf8",
@@ -1902,40 +2385,26 @@ function createGitHubClient(runner = execFileRunner) {
1902
2385
  for (const [index, pullRequest] of pullRequests.entries()) {
1903
2386
  args.push("-f", `url${index}=${pullRequest.url}`);
1904
2387
  }
1905
- let stdout;
1906
- let processFailure;
1907
- try {
1908
- const output = await runner("gh", args, options);
1909
- stdout = output.stdout;
1910
- } catch (cause) {
1911
- if (isCancellation(cause, options.signal)) {
1912
- return {
1913
- ok: false,
1914
- error: {
1915
- tag: "GitHubCancelled",
1916
- message: "GitHub status request cancelled",
1917
- cause
1918
- }
1919
- };
1920
- }
1921
- processFailure = classifyProcessFailure(cause);
1922
- const partialStdout = processFailureStdout(cause);
1923
- if (partialStdout !== undefined) {
1924
- stdout = partialStdout;
1925
- } else {
1926
- return { ok: false, error: processFailure };
1927
- }
1928
- }
1929
- let decoded;
1930
- try {
1931
- decoded = JSON.parse(stdout);
1932
- } catch {
1933
- 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);
1934
2406
  }
1935
- const parsed = parseBatchResponse(decoded, pullRequests);
1936
- if (processFailure === undefined || parsed.ok)
1937
- return parsed;
1938
- return { ok: false, error: processFailure };
2407
+ return cancellation === undefined ? { ok: true, value: batch } : { ok: false, error: cancellation };
1939
2408
  }
1940
2409
  };
1941
2410
  }
@@ -1949,6 +2418,7 @@ var diagnosticLabels = {
1949
2418
  GitHubCliMissing: "install gh",
1950
2419
  GitHubAuthenticationRequired: "run gh auth login",
1951
2420
  GitHubUnavailable: "GitHub unavailable",
2421
+ PullRequestNotFound: "not found or inaccessible",
1952
2422
  InvalidGitHubResponse: "invalid GitHub response"
1953
2423
  };
1954
2424
  function stateAppearance(state) {
@@ -1959,7 +2429,23 @@ function stateAppearance(state) {
1959
2429
  return { tone: "red", label: "merge conflict", strikethrough: false };
1960
2430
  case "mergeable":
1961
2431
  case "unknown":
1962
- 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
+ }
1963
2449
  default:
1964
2450
  return casesHandled(state.mergeability);
1965
2451
  }
@@ -1984,441 +2470,1095 @@ function statusAppearance(status) {
1984
2470
  return status.stale ? { ...appearance, label: `${appearance.label} (stale; ${diagnosticLabels[status.diagnostic]})` } : appearance;
1985
2471
  }
1986
2472
 
1987
- // src/attach.ts
1988
- var invalidPullRequestInput = {
1989
- ok: false,
1990
- error: {
1991
- tag: "InvalidPullRequestInput",
1992
- 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
+ };
1993
2486
  }
1994
- };
1995
- var repositoryResolutionFailed = {
1996
- tag: "RepositoryResolutionFailed",
1997
- message: "Unable to resolve the current GitHub repository with gh; attach with a full URL instead"
1998
- };
1999
- var repositoryResolutionCancelled = {
2000
- ok: false,
2001
- error: { tag: "RepositoryResolutionCancelled" }
2002
- };
2003
- function isCancellation2(cause, signal) {
2004
- if (signal?.aborted)
2005
- return true;
2006
- return cause instanceof Error && cause.name === "AbortError";
2007
- }
2008
- function parseRepositoryPullRequest(stdout, number) {
2009
- let decoded;
2010
2487
  try {
2011
- decoded = JSON.parse(stdout);
2012
- } catch {
2013
- return { ok: false, error: repositoryResolutionFailed };
2014
- }
2015
- if (decoded === null || typeof decoded !== "object" || !("url" in decoded) || typeof decoded.url !== "string" || decoded.url === "") {
2016
- 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
+ };
2017
2495
  }
2018
- const repositoryUrl = decoded.url.endsWith("/") ? decoded.url.slice(0, -1) : decoded.url;
2019
- const pullRequest = parsePullRequestUrl(`${repositoryUrl}/pull/${number}`);
2020
- return pullRequest.ok ? pullRequest : { ok: false, error: repositoryResolutionFailed };
2021
2496
  }
2022
- async function resolvePullRequestInput(input, options) {
2023
- const direct = parsePullRequestUrl(input);
2024
- if (direct.ok)
2025
- return direct;
2026
- if (input.trim() !== input || !/^\d+$/.test(input))
2027
- return invalidPullRequestInput;
2028
- const number = Number(input);
2029
- if (!Number.isSafeInteger(number) || number <= 0)
2030
- return invalidPullRequestInput;
2031
- let stdout;
2032
- try {
2033
- const result = await (options.runner ?? execFileRunner)("gh", ["repo", "view", "--json", "url"], {
2034
- cwd: options.directory,
2035
- ...options.signal ? { signal: options.signal } : {}
2036
- });
2037
- stdout = result.stdout;
2038
- } catch (cause) {
2039
- if (isCancellation2(cause, options.signal))
2040
- return repositoryResolutionCancelled;
2041
- 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
+ };
2042
2506
  }
2043
- 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
+ };
2044
2515
  }
2045
2516
 
2046
- // src/state.ts
2047
- var import_proper_lockfile = __toESM(require_proper_lockfile(), 1);
2048
- import { createHash, randomUUID } from "crypto";
2049
- import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
2050
- import { homedir } from "os";
2051
- import { join } from "path";
2052
- var maximumPullRequestsPerSession = 20;
2053
- var invalidStateFile = {
2054
- ok: false,
2055
- error: {
2056
- tag: "InvalidStateFile",
2057
- 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;
2058
2521
  }
2059
- };
2060
- var lockStaleMilliseconds = 1e4;
2061
- var lockUpdateMilliseconds = 2000;
2062
- function stateUnavailable(operation, message, cause) {
2063
- 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
+ };
2064
2529
  }
2065
- function isRecord2(value) {
2066
- 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";
2067
2535
  }
2068
- function hasExactKeys(value, keys) {
2069
- const actual = Object.keys(value);
2070
- 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));
2071
2542
  }
2072
- function parseState(input) {
2073
- if (!isRecord2(input) || !hasExactKeys(input, ["version", "pullRequests"]))
2074
- return invalidStateFile;
2075
- if (input.version !== 1 || !Array.isArray(input.pullRequests))
2076
- return invalidStateFile;
2077
- if (input.pullRequests.length > maximumPullRequestsPerSession)
2078
- return invalidStateFile;
2079
- const attachments = [];
2080
- const seen = new Set;
2081
- for (const item of input.pullRequests) {
2082
- if (!isRecord2(item) || !hasExactKeys(item, ["url", "attachedAt"]))
2083
- return invalidStateFile;
2084
- if (typeof item.url !== "string" || typeof item.attachedAt !== "string")
2085
- return invalidStateFile;
2086
- const parsed = parsePullRequestUrl(item.url);
2087
- if (!parsed.ok || parsed.value.url !== item.url || seen.has(item.url))
2088
- return invalidStateFile;
2089
- const attachedAt = new Date(item.attachedAt);
2090
- if (Number.isNaN(attachedAt.valueOf()) || attachedAt.toISOString() !== item.attachedAt)
2091
- return invalidStateFile;
2092
- seen.add(item.url);
2093
- 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}`);
2094
2567
  }
2095
- return { ok: true, value: attachments };
2568
+ return {
2569
+ ok: true,
2570
+ value: {
2571
+ ...draft,
2572
+ body: [draft.body, "", "## Diagnostics", "", ...lines].join(`
2573
+ `)
2574
+ }
2575
+ };
2096
2576
  }
2097
- function isMissingFile(cause) {
2098
- 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
+ }
2099
2645
  }
2100
- function fileName(sessionID) {
2101
- 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();
2102
2653
  }
2103
- function defaultStateDirectory(environment = process.env, home = homedir()) {
2104
- const dataHome = environment.XDG_DATA_HOME || join(home, ".local", "share");
2105
- 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
+ };
2106
2682
  }
2107
- function createStateStore(options = {}) {
2108
- const directory = options.directory ?? defaultStateDirectory();
2109
- const now = options.now ?? (() => new Date);
2110
- async function acquireLock(sessionID) {
2111
- const stateFile = join(directory, fileName(sessionID));
2112
- let compromised;
2113
- try {
2114
- await mkdir(directory, { recursive: true });
2115
- const release = await import_proper_lockfile.lock(stateFile, {
2116
- realpath: false,
2117
- stale: lockStaleMilliseconds,
2118
- update: lockUpdateMilliseconds,
2119
- retries: { retries: 50, factor: 1, minTimeout: 10, maxTimeout: 100 },
2120
- onCompromised: (error) => {
2121
- 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
2122
2710
  }
2123
- });
2124
- return { ok: true, value: { release, compromised: () => compromised } };
2125
- } catch (cause) {
2711
+ };
2712
+ }
2713
+ if (failure !== undefined && isAuthenticationFailure2(failure)) {
2126
2714
  return {
2127
2715
  ok: false,
2128
- 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
+ }
2129
2721
  };
2130
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
+ };
2131
2731
  }
2132
- async function withLock(sessionID, operation) {
2133
- const lock = await acquireLock(sessionID);
2134
- if (!lock.ok)
2135
- return lock;
2136
- let result;
2137
- try {
2138
- result = await operation();
2139
- } catch (cause) {
2140
- 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)
2141
2798
  return;
2142
- });
2143
- 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;
2144
2808
  }
2145
- try {
2146
- await lock.value.release();
2147
- } 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;
2148
2925
  return {
2149
- ok: false,
2150
- error: stateUnavailable("write", "Unable to unlock the session pull request state", cause)
2926
+ kind,
2927
+ title,
2928
+ problem,
2929
+ reproduction,
2930
+ expectedBehavior
2151
2931
  };
2152
2932
  }
2153
- const compromise = lock.value.compromised();
2154
- 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;
2155
2943
  return {
2156
- ok: false,
2157
- error: stateUnavailable("write", "The session pull request state lock was compromised", compromise)
2944
+ kind,
2945
+ title,
2946
+ problem,
2947
+ desiredOutcome,
2948
+ constraints
2158
2949
  };
2159
2950
  }
2160
- return result;
2161
- }
2162
- async function readExisting(sessionID) {
2163
- const path = join(directory, fileName(sessionID));
2164
- let content;
2165
- try {
2166
- content = await readFile(path, "utf8");
2167
- } catch (cause) {
2168
- if (isMissingFile(cause))
2169
- return { ok: true, value: undefined };
2951
+ case "other": {
2952
+ const details = await promptForValue(api, signal, "Details", true);
2953
+ if (details === undefined)
2954
+ return;
2170
2955
  return {
2171
- ok: false,
2172
- error: stateUnavailable("read", "Unable to read the session pull request state", cause)
2956
+ kind,
2957
+ title,
2958
+ details
2173
2959
  };
2174
2960
  }
2175
- let decoded;
2176
- try {
2177
- decoded = JSON.parse(content);
2178
- } catch {
2179
- return invalidStateFile;
2180
- }
2181
- return parseState(decoded);
2182
- }
2183
- async function read(sessionID) {
2184
- const result = await readExisting(sessionID);
2185
- if (!result.ok)
2186
- return result;
2187
- return { ok: true, value: result.value ?? [] };
2961
+ default:
2962
+ return casesHandled(kind);
2188
2963
  }
2189
- async function write(sessionID, attachments) {
2190
- const destination = join(directory, fileName(sessionID));
2191
- const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
2192
- const state = {
2193
- version: 1,
2194
- pullRequests: attachments.map((attachment) => ({
2195
- url: attachment.pullRequest.url,
2196
- attachedAt: attachment.attachedAt
2197
- }))
2198
- };
2199
- try {
2200
- await mkdir(directory, { recursive: true });
2201
- await writeFile(temporary, `${JSON.stringify(state, null, 2)}
2202
- `, { mode: 384 });
2203
- await rename(temporary, destination);
2204
- return { ok: true, value: undefined };
2205
- } catch (cause) {
2206
- 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)
2207
2976
  return;
2208
- });
2209
- return {
2210
- ok: false,
2211
- 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"
2212
2985
  };
2213
- }
2214
- }
2215
- return {
2216
- list: read,
2217
- async attach(sessionID, pullRequest) {
2218
- return withLock(sessionID, async () => {
2219
- const current = await read(sessionID);
2220
- if (!current.ok)
2221
- return current;
2222
- if (current.value.some((attachment) => attachment.pullRequest.url === pullRequest.url)) {
2223
- return { ok: true, value: "already_attached" };
2224
- }
2225
- if (current.value.length >= maximumPullRequestsPerSession) {
2226
- return {
2227
- ok: false,
2228
- error: {
2229
- tag: "AttachmentLimitReached",
2230
- limit: maximumPullRequestsPerSession,
2231
- message: "A session can track at most 20 pull requests"
2232
- }
2233
- };
2234
- }
2235
- const written = await write(sessionID, [...current.value, { pullRequest, attachedAt: now().toISOString() }]);
2236
- if (!written.ok)
2237
- return written;
2238
- return { ok: true, value: "added" };
2239
- });
2240
- },
2241
- async detach(sessionID, pullRequest) {
2242
- return withLock(sessionID, async () => {
2243
- const current = await read(sessionID);
2244
- if (!current.ok)
2245
- return current;
2246
- const next = current.value.filter((attachment) => attachment.pullRequest.url !== pullRequest.url);
2247
- if (next.length === current.value.length)
2248
- return { ok: true, value: "absent" };
2249
- const written = await write(sessionID, next);
2250
- if (!written.ok)
2251
- return written;
2252
- return { ok: true, value: "removed" };
2253
- });
2254
- },
2255
- async detachByNumber(sessionID, number) {
2256
- return withLock(sessionID, async () => {
2257
- const current = await read(sessionID);
2258
- if (!current.ok)
2259
- return current;
2260
- const matches = current.value.filter((attachment) => attachment.pullRequest.number === number);
2261
- if (matches.length === 0)
2262
- return { ok: true, value: { tag: "absent" } };
2263
- if (matches.length > 1) {
2264
- return {
2265
- ok: true,
2266
- value: { tag: "ambiguous", pullRequests: matches.map((attachment) => attachment.pullRequest) }
2267
- };
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
+ });
2268
3020
  }
2269
- const match = matches[0];
2270
- if (match === undefined)
2271
- return { ok: true, value: { tag: "absent" } };
2272
- const next = current.value.filter((attachment) => attachment.pullRequest.url !== match.pullRequest.url);
2273
- const written = await write(sessionID, next);
2274
- if (!written.ok)
2275
- return written;
2276
- 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
2277
3028
  });
2278
- },
2279
- async removeSession(sessionID) {
2280
- return withLock(sessionID, async () => {
2281
- const current = await readExisting(sessionID);
2282
- if (!current.ok)
2283
- return current;
2284
- if (current.value === undefined)
2285
- return { ok: true, value: "absent" };
2286
- try {
2287
- await rm(join(directory, fileName(sessionID)), { force: true });
2288
- return { ok: true, value: "removed" };
2289
- } catch (cause) {
2290
- return {
2291
- ok: false,
2292
- error: stateUnavailable("write", "Unable to remove the session pull request state", cause)
2293
- };
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
+ });
2294
3036
  }
2295
- });
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
+ }
2296
3044
  }
2297
3045
  };
2298
3046
  }
2299
3047
 
2300
- // src/tui.tsx
2301
- var pollIntervalMilliseconds = 60000;
2302
- var defaultScheduler = {
2303
- setInterval: (task, delay) => globalThis.setInterval(task, delay),
2304
- clearInterval: (handle) => globalThis.clearInterval(handle)
2305
- };
2306
- function attachPullRequest(store, sessionID, input) {
2307
- const pullRequest = parsePullRequestUrl(input);
2308
- if (!pullRequest.ok)
2309
- return Promise.resolve(pullRequest);
2310
- 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);
2311
3061
  }
2312
- function startSessionPolling(input) {
2313
- const scheduler = input.scheduler ?? defaultScheduler;
2314
- const statuses = new Map;
2315
- const controller = new AbortController;
2316
- let timer;
2317
- let timerRegistered = false;
2318
- let stopped = false;
2319
- let inFlight;
2320
- let queued;
2321
- function project(attachments) {
2322
- return attachments.map((attachment) => ({
2323
- attachment,
2324
- status: statuses.get(attachment.pullRequest.url) ?? {
2325
- tag: "Unavailable"
2326
- }
2327
- }));
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 });
2328
3070
  }
2329
- async function poll() {
2330
- const attachments = await input.store.list(input.sessionID);
2331
- if (stopped)
2332
- return {
2333
- ok: true,
2334
- value: "stopped"
2335
- };
2336
- if (!attachments.ok) {
2337
- input.publish([]);
2338
- input.onStateFailure(attachments.error);
2339
- return attachments;
2340
- }
2341
- const attachedUrls = new Set(attachments.value.map((attachment) => attachment.pullRequest.url));
2342
- for (const url of statuses.keys()) {
2343
- if (!attachedUrls.has(url))
2344
- statuses.delete(url);
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))
3092
+ continue;
3093
+ if (latest === undefined || Bun.semver.order(release.version, latest) > 0)
3094
+ latest = release.version;
3095
+ } catch {
3096
+ continue;
2345
3097
  }
2346
- input.publish(project(attachments.value));
2347
- if (attachments.value.length === 0)
2348
- return {
2349
- ok: true,
2350
- value: "no_attachments"
2351
- };
2352
- const refreshable = attachments.value.filter((attachment) => {
2353
- const previous = statuses.get(attachment.pullRequest.url);
2354
- return previous?.tag !== "Available" || previous.state.tag !== "Merged";
2355
- });
2356
- const batch = await input.github.get(refreshable.map((attachment) => attachment.pullRequest), {
2357
- signal: controller.signal
2358
- });
2359
- if (stopped)
2360
- return {
2361
- ok: true,
2362
- value: "stopped"
2363
- };
2364
- let batchDiagnostic;
2365
- let failure;
2366
- if (!batch.ok) {
2367
- if (batch.error.tag === "GitHubCancelled")
2368
- return batch;
2369
- batchDiagnostic = batch.error.tag === "GitHubBatchLimitExceeded" ? "GitHubUnavailable" : batch.error.tag;
2370
- failure = batch.error;
3098
+ }
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" } };
2371
3112
  }
2372
- for (const [index, attachment] of refreshable.entries()) {
2373
- const previous = statuses.get(attachment.pullRequest.url);
2374
- const result = batch.ok ? batch.value[index] : undefined;
2375
- if (result?.ok) {
2376
- statuses.set(attachment.pullRequest.url, result.value);
2377
- continue;
3113
+ return {
3114
+ ok: false,
3115
+ error: { tag: "UpdateCheckUnavailable", message: "Unable to check for plugin updates", cause }
3116
+ };
3117
+ }
3118
+ if (!response.ok) {
3119
+ return {
3120
+ ok: false,
3121
+ error: {
3122
+ tag: "UpdateCheckUnavailable",
3123
+ message: "Unable to check for plugin updates",
3124
+ cause: new Error(`npm registry returned HTTP ${response.status}`)
2378
3125
  }
2379
- const diagnostic = result === undefined ? batchDiagnostic ?? "GitHubUnavailable" : result.error.tag;
2380
- if (result !== undefined && !result.ok)
2381
- failure ??= result.error;
2382
- statuses.set(attachment.pullRequest.url, previous?.tag === "Available" ? {
2383
- ...previous,
2384
- stale: true,
2385
- diagnostic
2386
- } : {
2387
- tag: "Unavailable",
2388
- diagnostic
2389
- });
2390
- }
2391
- if (!stopped)
2392
- input.publish(project(attachments.value));
2393
- return failure === undefined ? {
2394
- ok: true,
2395
- value: "refreshed"
2396
- } : {
3126
+ };
3127
+ }
3128
+ let decoded;
3129
+ try {
3130
+ decoded = await response.json();
3131
+ } catch {
3132
+ return {
2397
3133
  ok: false,
2398
- error: failure
3134
+ error: {
3135
+ tag: "InvalidUpdateResponse",
3136
+ message: "The npm registry returned invalid plugin release metadata"
3137
+ }
2399
3138
  };
2400
3139
  }
2401
- function startQueuedRefresh() {
2402
- inFlight = undefined;
2403
- const next = queued;
2404
- queued = undefined;
2405
- if (next === undefined)
2406
- return;
2407
- if (stopped) {
2408
- next.resolve({
2409
- ok: true,
2410
- value: "stopped"
2411
- });
3140
+ const releases = parseRegistryReleases(decoded);
3141
+ if (releases === undefined) {
3142
+ return {
3143
+ ok: false,
3144
+ error: {
3145
+ tag: "InvalidUpdateResponse",
3146
+ message: "The npm registry returned invalid plugin release metadata"
3147
+ }
3148
+ };
3149
+ }
3150
+ return { ok: true, value: newestCompatibleUpdate(releases, versions) };
3151
+ }
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))
2412
3168
  return;
2413
- }
2414
- requestRefresh().then((result) => next.resolve(result), (error) => next.reject(error));
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);
3228
+ }
3229
+ };
3230
+ }
3231
+ function writeUpdateCache(api, versions, availableVersion, now) {
3232
+ const cache = {
3233
+ checkedAt: now(),
3234
+ ...versions,
3235
+ availableVersion
3236
+ };
3237
+ api.kv.set(updateCacheKey, cache);
3238
+ }
3239
+ function waitForKvReady(api) {
3240
+ return new Promise((resolve) => {
3241
+ let timer;
3242
+ const finish = (ready) => {
3243
+ if (timer !== undefined)
3244
+ clearTimeout(timer);
3245
+ api.lifecycle.signal.removeEventListener("abort", onAbort);
3246
+ resolve(ready);
3247
+ };
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
3260
+ });
3261
+ check();
3262
+ });
3263
+ }
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
+ });
3325
+ return;
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));
2415
3558
  }
2416
3559
  function requestRefresh() {
2417
3560
  if (stopped)
2418
- return Promise.resolve({
2419
- ok: true,
2420
- value: "stopped"
2421
- });
3561
+ return Promise.resolve({ ok: true, value: "stopped" });
2422
3562
  if (inFlight) {
2423
3563
  if (queued !== undefined)
2424
3564
  return queued.promise;
@@ -2428,11 +3568,7 @@ function startSessionPolling(input) {
2428
3568
  resolve = resolvePromise;
2429
3569
  reject = rejectPromise;
2430
3570
  });
2431
- queued = {
2432
- promise,
2433
- resolve,
2434
- reject
2435
- };
3571
+ queued = { promise, resolve, reject };
2436
3572
  return promise;
2437
3573
  }
2438
3574
  const current = poll();
@@ -2469,37 +3605,18 @@ function startSessionPolling(input) {
2469
3605
  }
2470
3606
  };
2471
3607
  }
2472
- async function openPullRequest(pullRequest, options = {}) {
2473
- const platform = options.platform ?? process.platform;
2474
- const executable = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : undefined;
2475
- if (executable === undefined) {
2476
- return {
2477
- ok: false,
2478
- error: {
2479
- tag: "UnsupportedPlatform",
2480
- message: `Opening pull requests is unsupported on ${platform}`,
2481
- platform
2482
- }
2483
- };
2484
- }
2485
- try {
2486
- await (options.runner ?? execFileRunner)(executable, [pullRequest.url], options.signal ? {
2487
- signal: options.signal
2488
- } : {});
2489
- return {
2490
- ok: true,
2491
- value: undefined
2492
- };
2493
- } catch (cause) {
2494
- return {
2495
- ok: false,
2496
- error: {
2497
- tag: "OpenPullRequestFailed",
2498
- message: "Unable to open the pull request",
2499
- cause
2500
- }
2501
- };
2502
- }
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
+ } : {});
2503
3620
  }
2504
3621
  function createRefreshBus() {
2505
3622
  const listeners = new Map;
@@ -2542,26 +3659,36 @@ function promptForPullRequest(api, options) {
2542
3659
  const controller = new AbortController;
2543
3660
  const signal = AbortSignal.any([options.signal, controller.signal]);
2544
3661
  let finished = false;
2545
- const finish = (value) => {
3662
+ const onAbort = () => finish(undefined);
3663
+ const finish = (value, clearDialog = true) => {
2546
3664
  if (finished)
2547
3665
  return;
2548
3666
  finished = true;
3667
+ options.signal.removeEventListener("abort", onAbort);
2549
3668
  controller.abort();
2550
- api.ui.dialog.clear();
3669
+ if (clearDialog)
3670
+ api.ui.dialog.clear();
2551
3671
  resolve(value);
2552
3672
  };
3673
+ if (options.signal.aborted) {
3674
+ finish(undefined, false);
3675
+ return;
3676
+ }
3677
+ options.signal.addEventListener("abort", onAbort, {
3678
+ once: true
3679
+ });
2553
3680
  api.ui.dialog.setSize("medium");
2554
3681
  api.ui.dialog.replace(() => {
2555
- const [error, setError] = createSignal();
2556
- const [busy, setBusy] = createSignal(false);
3682
+ const [error, setError] = createSignal2();
3683
+ const [busy, setBusy] = createSignal2(false);
2557
3684
  const DialogPrompt = api.ui.DialogPrompt;
2558
- return _$createComponent(DialogPrompt, {
3685
+ return _$createComponent3(DialogPrompt, {
2559
3686
  title: "Attach pull request",
2560
- placeholder: "https://github.com/owner/repository/pull/123 or 123",
3687
+ placeholder: "https://github.com/owner/repository/pull/123, github.com/owner/repository/pull/123, or 123",
2561
3688
  description: () => error() ? (() => {
2562
- var _el$ = _$createElement("text");
2563
- _$insert(_el$, error);
2564
- _$effect((_$p) => _$setProp(_el$, "fg", api.theme.current.error, _$p));
3689
+ var _el$ = _$createElement2("text");
3690
+ _$insert2(_el$, error);
3691
+ _$effect2((_$p) => _$setProp2(_el$, "fg", api.theme.current.error, _$p));
2565
3692
  return _el$;
2566
3693
  })() : null,
2567
3694
  get busy() {
@@ -2569,7 +3696,7 @@ function promptForPullRequest(api, options) {
2569
3696
  },
2570
3697
  busyText: "Resolving repository",
2571
3698
  onConfirm: (value) => {
2572
- if (busy())
3699
+ if (finished || busy())
2573
3700
  return;
2574
3701
  setBusy(true);
2575
3702
  resolvePullRequestInput(value, {
@@ -2595,395 +3722,753 @@ function promptForPullRequest(api, options) {
2595
3722
  },
2596
3723
  onCancel: () => finish(undefined)
2597
3724
  });
2598
- }, () => {
2599
- if (finished)
2600
- return;
2601
- finished = true;
2602
- controller.abort();
2603
- resolve(undefined);
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
+ }))
4319
+ };
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;
4329
+ });
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" };
4344
+ }
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
+ };
4354
+ }
4355
+ const validation = await attachOptions.validate();
4356
+ if (!validation.ok)
4357
+ return validation;
4358
+ }
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" };
4365
+ }
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
+ };
4375
+ }
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
+ });
2604
4381
  });
2605
- });
2606
- }
2607
- function selectPullRequest(api, title, attachments) {
2608
- return new Promise((resolve) => {
2609
- let finished = false;
2610
- const finish = (value) => {
2611
- if (finished)
2612
- return;
2613
- finished = true;
2614
- api.ui.dialog.clear();
2615
- resolve(value);
2616
- };
2617
- api.ui.dialog.setSize("medium");
2618
- api.ui.dialog.replace(() => {
2619
- const DialogSelect = api.ui.DialogSelect;
2620
- return _$createComponent(DialogSelect, {
2621
- title,
2622
- get options() {
2623
- return attachments.map((attachment) => ({
2624
- title: formatPullRequestRef(attachment.pullRequest),
2625
- value: attachment.pullRequest,
2626
- description: attachment.pullRequest.url
2627
- }));
2628
- },
2629
- onSelect: (option) => finish(option.value)
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" };
2630
4398
  });
2631
- }, () => {
2632
- if (!finished)
2633
- resolve(undefined);
2634
- });
2635
- });
2636
- }
2637
- function showStateFailure(api, failure) {
2638
- api.ui.toast({
2639
- variant: "error",
2640
- title: "Pull request tracker",
2641
- message: failure.message
2642
- });
2643
- }
2644
- function toneColor(theme, tone) {
2645
- const colors = {
2646
- green: theme.success,
2647
- yellow: theme.warning,
2648
- red: theme.error,
2649
- purple: theme.secondary,
2650
- gray: theme.textMuted
2651
- };
2652
- return colors[tone];
2653
- }
2654
- function PullRequestSidebar(props) {
2655
- const [items, setItems] = createSignal([]);
2656
- const [failure, setFailure] = createSignal();
2657
- const polling = startSessionPolling({
2658
- sessionID: props.sessionID,
2659
- store: props.dependencies.store,
2660
- github: props.dependencies.github,
2661
- publish: (value) => {
2662
- setFailure(undefined);
2663
- setItems(value);
2664
4399
  },
2665
- onStateFailure: (error) => setFailure(error.message),
2666
- onError: () => setFailure("Unable to refresh pull request status")
2667
- });
2668
- polling.start().catch(() => setFailure("Unable to refresh pull request status"));
2669
- const unsubscribe = props.refreshBus.subscribe(props.sessionID, {
2670
- refresh() {
2671
- polling.refresh().catch(() => setFailure("Unable to refresh pull request status"));
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
+ };
4413
+ }
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
+ });
2672
4423
  },
2673
- async forceRefresh() {
2674
- try {
2675
- return await polling.forceRefresh();
2676
- } catch (error) {
2677
- setFailure("Unable to refresh pull request status");
2678
- throw error;
2679
- }
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
+ };
4439
+ }
4440
+ });
2680
4441
  }
2681
- });
2682
- const onAbort = () => polling.stop();
2683
- props.api.lifecycle.signal.addEventListener("abort", onAbort, {
2684
- once: true
2685
- });
2686
- onCleanup(() => {
2687
- unsubscribe();
2688
- polling.stop();
2689
- props.api.lifecycle.signal.removeEventListener("abort", onAbort);
2690
- });
2691
- return (() => {
2692
- var _el$2 = _$createElement("box"), _el$3 = _$createElement("text"), _el$4 = _$createElement("b");
2693
- _$insertNode(_el$2, _el$3);
2694
- _$setProp(_el$2, "flexDirection", "column");
2695
- _$setProp(_el$2, "gap", 1);
2696
- _$insertNode(_el$3, _el$4);
2697
- _$insertNode(_el$4, _$createTextNode(`Pull requests`));
2698
- _$insert(_el$2, (() => {
2699
- var _c$ = _$memo(() => !!failure());
2700
- return () => _c$() ? (() => {
2701
- var _el$6 = _$createElement("text");
2702
- _$insert(_el$6, failure);
2703
- _$effect((_$p) => _$setProp(_el$6, "fg", props.api.theme.current.error, _$p));
2704
- return _el$6;
2705
- })() : null;
2706
- })(), null);
2707
- _$insert(_el$2, (() => {
2708
- var _c$2 = _$memo(() => !!(!failure() && items().length === 0));
2709
- return () => _c$2() ? (() => {
2710
- var _el$7 = _$createElement("text");
2711
- _$insertNode(_el$7, _$createTextNode(`No pull requests attached`));
2712
- _$effect((_$p) => _$setProp(_el$7, "fg", props.api.theme.current.textMuted, _$p));
2713
- return _el$7;
2714
- })() : null;
2715
- })(), null);
2716
- _$insert(_el$2, () => items().map((item) => {
2717
- const appearance = statusAppearance(item.status);
2718
- const attributes = appearance.strikethrough ? TextAttributes.STRIKETHROUGH : TextAttributes.NONE;
2719
- const title = item.status.tag === "Available" ? item.status.title : "Title unavailable";
2720
- return (() => {
2721
- var _el$9 = _$createElement("box"), _el$0 = _$createElement("text"), _el$1 = _$createElement("b"), _el$10 = _$createTextNode(` `), _el$11 = _$createElement("text");
2722
- _$insertNode(_el$9, _el$0);
2723
- _$insertNode(_el$9, _el$11);
2724
- _$setProp(_el$9, "flexDirection", "column");
2725
- _$setProp(_el$9, "onMouseUp", () => {
2726
- openPullRequest(item.attachment.pullRequest, {
2727
- ...props.dependencies.runner ? {
2728
- runner: props.dependencies.runner
2729
- } : {},
2730
- signal: props.api.lifecycle.signal
2731
- }).then((result) => {
2732
- if (!result.ok) {
2733
- props.api.ui.toast({
2734
- variant: "error",
2735
- title: "Pull request tracker",
2736
- message: result.error.message
2737
- });
2738
- }
2739
- }).catch(() => {
2740
- props.api.ui.toast({
2741
- variant: "error",
2742
- title: "Pull request tracker",
2743
- message: "Unable to open the pull request"
2744
- });
2745
- });
2746
- });
2747
- _$insertNode(_el$0, _el$1);
2748
- _$insertNode(_el$0, _el$10);
2749
- _$setProp(_el$0, "attributes", attributes);
2750
- _$insert(_el$1, () => formatPullRequestRef(item.attachment.pullRequest));
2751
- _$insert(_el$0, () => appearance.label, null);
2752
- _$setProp(_el$11, "attributes", attributes);
2753
- _$insert(_el$11, title);
2754
- _$effect((_p$) => {
2755
- var _v$ = toneColor(props.api.theme.current, appearance.tone), _v$2 = props.api.theme.current.textMuted;
2756
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$0, "fg", _v$, _p$.e));
2757
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$11, "fg", _v$2, _p$.t));
2758
- return _p$;
2759
- }, {
2760
- e: undefined,
2761
- t: undefined
2762
- });
2763
- return _el$9;
2764
- })();
2765
- }), null);
2766
- _$effect((_$p) => _$setProp(_el$3, "fg", props.api.theme.current.text, _$p));
2767
- return _el$2;
2768
- })();
4442
+ };
2769
4443
  }
2770
- function registerTui(api, dependencies) {
4444
+
4445
+ // src/tui.tsx
4446
+ function registerTui(api, dependencies, release) {
2771
4447
  const refreshBus = dependencies.refreshBus ?? createRefreshBus();
4448
+ const updates = createPluginUpdateController(api, dependencies, release);
2772
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))];
2773
4450
  const disposeCommands = api.keymap.registerLayer({
2774
- commands: [{
2775
- name: "pr.attach",
2776
- title: "Attach pull request",
2777
- category: "Plugin",
2778
- namespace: "palette",
2779
- slashName: "pr-attach",
2780
- async run() {
2781
- const sessionID = currentSessionID(api);
2782
- if (sessionID === undefined) {
2783
- api.ui.toast({
2784
- variant: "warning",
2785
- title: "Pull request tracker",
2786
- message: "Open a session first"
2787
- });
2788
- return;
2789
- }
2790
- const pullRequest = await promptForPullRequest(api, {
2791
- directory: api.state.path.directory,
2792
- ...dependencies.runner ? {
2793
- runner: dependencies.runner
2794
- } : {},
2795
- signal: api.lifecycle.signal
2796
- });
2797
- if (pullRequest === undefined)
2798
- return;
2799
- const result = await dependencies.store.attach(sessionID, pullRequest);
2800
- if (!result.ok) {
2801
- showStateFailure(api, result.error);
2802
- return;
2803
- }
2804
- const message = result.value === "added" ? `Attached ${formatPullRequestRef(pullRequest)}` : `${formatPullRequestRef(pullRequest)} is already attached`;
2805
- api.ui.toast({
2806
- variant: "success",
2807
- title: "Pull request tracker",
2808
- message
2809
- });
2810
- refreshBus.emit(sessionID);
2811
- }
2812
- }, {
2813
- name: "pr.open",
2814
- title: "Open pull request",
2815
- category: "Plugin",
2816
- namespace: "palette",
2817
- slashName: "pr-open",
2818
- async run() {
2819
- const sessionID = currentSessionID(api);
2820
- if (sessionID === undefined) {
2821
- api.ui.toast({
2822
- variant: "warning",
2823
- title: "Pull request tracker",
2824
- message: "Open a session first"
2825
- });
2826
- return;
2827
- }
2828
- const attachments = await dependencies.store.list(sessionID);
2829
- if (!attachments.ok) {
2830
- showStateFailure(api, attachments.error);
2831
- return;
2832
- }
2833
- if (attachments.value.length === 0) {
2834
- api.ui.toast({
2835
- variant: "info",
2836
- title: "Pull request tracker",
2837
- message: "No pull requests are attached"
2838
- });
2839
- return;
2840
- }
2841
- const pullRequest = await selectPullRequest(api, "Open pull request", attachments.value);
2842
- if (pullRequest === undefined)
2843
- return;
2844
- const result = await openPullRequest(pullRequest, {
2845
- ...dependencies.runner ? {
2846
- runner: dependencies.runner
2847
- } : {},
2848
- signal: api.lifecycle.signal
2849
- });
2850
- if (!result.ok) {
2851
- api.ui.toast({
2852
- variant: "error",
2853
- title: "Pull request tracker",
2854
- message: result.error.message
2855
- });
2856
- }
2857
- }
2858
- }, {
2859
- name: "pr.detach",
2860
- title: "Detach pull request",
2861
- category: "Plugin",
2862
- namespace: "palette",
2863
- slashName: "pr-detach",
2864
- async run() {
2865
- const sessionID = currentSessionID(api);
2866
- if (sessionID === undefined) {
2867
- api.ui.toast({
2868
- variant: "warning",
2869
- title: "Pull request tracker",
2870
- message: "Open a session first"
2871
- });
2872
- return;
2873
- }
2874
- const attachments = await dependencies.store.list(sessionID);
2875
- if (!attachments.ok) {
2876
- showStateFailure(api, attachments.error);
2877
- return;
2878
- }
2879
- if (attachments.value.length === 0) {
2880
- api.ui.toast({
2881
- variant: "info",
2882
- title: "Pull request tracker",
2883
- message: "No pull requests are attached"
2884
- });
2885
- return;
2886
- }
2887
- const pullRequest = await selectPullRequest(api, "Detach pull request", attachments.value);
2888
- if (pullRequest === undefined)
2889
- return;
2890
- const result = await dependencies.store.detach(sessionID, pullRequest);
2891
- if (!result.ok) {
2892
- showStateFailure(api, result.error);
2893
- return;
2894
- }
2895
- const message = result.value === "removed" ? `Detached ${formatPullRequestRef(pullRequest)}` : `${formatPullRequestRef(pullRequest)} was not attached`;
2896
- api.ui.toast({
2897
- variant: "success",
2898
- title: "Pull request tracker",
2899
- message
2900
- });
2901
- refreshBus.emit(sessionID);
2902
- }
2903
- }, {
2904
- name: "pr.sync",
2905
- title: "Sync pull request status",
2906
- category: "Plugin",
2907
- namespace: "palette",
2908
- slashName: "pr-sync",
2909
- async run() {
2910
- const sessionID = currentSessionID(api);
2911
- if (sessionID === undefined) {
2912
- api.ui.toast({
2913
- variant: "warning",
2914
- title: "Pull request tracker",
2915
- message: "Open a session first"
2916
- });
2917
- return;
2918
- }
2919
- try {
2920
- const result = await refreshBus.forceRefresh(sessionID);
2921
- if (result === undefined) {
2922
- api.ui.toast({
2923
- variant: "warning",
2924
- title: "Pull request tracker",
2925
- message: "Pull request sidebar is not available"
2926
- });
2927
- return;
2928
- }
2929
- if (!result.ok) {
2930
- api.ui.toast({
2931
- variant: "error",
2932
- title: "Pull request tracker",
2933
- message: result.error.message
2934
- });
2935
- return;
2936
- }
2937
- switch (result.value) {
2938
- case "refreshed":
2939
- api.ui.toast({
2940
- variant: "success",
2941
- title: "Pull request tracker",
2942
- message: "Pull request status synced"
2943
- });
2944
- return;
2945
- case "no_attachments":
2946
- api.ui.toast({
2947
- variant: "info",
2948
- title: "Pull request tracker",
2949
- message: "No pull requests are attached"
2950
- });
2951
- return;
2952
- case "stopped":
2953
- api.ui.toast({
2954
- variant: "error",
2955
- title: "Pull request tracker",
2956
- message: "Unable to refresh pull request status"
2957
- });
2958
- return;
2959
- }
2960
- } catch {
2961
- api.ui.toast({
2962
- variant: "error",
2963
- title: "Pull request tracker",
2964
- message: "Unable to refresh pull request status"
2965
- });
2966
- }
2967
- }
2968
- }],
4451
+ commands: [...createPullRequestCommands(api, dependencies, refreshBus), updates.command, createFeedbackCommand(api, dependencies, release)],
2969
4452
  bindings: []
2970
4453
  });
2971
- api.lifecycle.onDispose(() => {
4454
+ api.lifecycle.onDispose(async () => {
2972
4455
  disposeCommands();
2973
4456
  for (const disposeEvent of disposeEvents)
2974
4457
  disposeEvent();
4458
+ await updates.startup;
2975
4459
  });
2976
4460
  api.slots.register({
2977
4461
  order: 250,
2978
4462
  slots: {
2979
4463
  sidebar_content(_context, value) {
2980
- return _$createComponent(PullRequestSidebar, {
4464
+ return _$createComponent4(PullRequestSidebar, {
2981
4465
  api,
2982
4466
  get sessionID() {
2983
4467
  return value.session_id;
2984
4468
  },
2985
4469
  dependencies,
2986
- refreshBus
4470
+ refreshBus,
4471
+ updates
2987
4472
  });
2988
4473
  }
2989
4474
  }
@@ -2991,22 +4476,23 @@ function registerTui(api, dependencies) {
2991
4476
  }
2992
4477
  var plugin = {
2993
4478
  id: "opencode-pr-tracker",
2994
- async tui(api, options) {
4479
+ async tui(api, options, meta) {
2995
4480
  if (options?.enabled === false)
2996
4481
  return;
2997
4482
  registerTui(api, {
2998
4483
  store: createStateStore(),
2999
4484
  github: createGitHubClient()
3000
- });
4485
+ }, meta);
3001
4486
  }
3002
4487
  };
3003
4488
  var tui_default = plugin;
3004
4489
  export {
4490
+ updateStatusLabel,
3005
4491
  startSessionPolling,
3006
4492
  registerTui,
3007
4493
  openPullRequest,
3008
4494
  tui_default as default,
3009
- attachPullRequest
4495
+ attachPullRequest2 as attachPullRequest
3010
4496
  };
3011
4497
 
3012
- //# debugId=4A2A207709F4E99D64756E2164756E21
4498
+ //# debugId=9AE11FCB9BB4D09764756E2164756E21