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