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