@wardby/cli 0.3.0 → 0.4.1
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/.env.example +22 -3
- package/README.md +11 -9
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +68 -10
- package/dist/coding/base-commit.d.ts +6 -0
- package/dist/coding/base-commit.js +12 -0
- package/dist/coding/protocol.d.ts +17 -1
- package/dist/coding/protocol.js +17 -6
- package/dist/coding/provider.d.ts +6 -1
- package/dist/coding/provider.js +16 -9
- package/dist/config/providers.d.ts +25 -0
- package/dist/config/providers.js +71 -0
- package/dist/core/attribution.d.ts +101 -0
- package/dist/core/attribution.js +208 -0
- package/dist/core/budget-groups.d.ts +17 -10
- package/dist/core/budget-groups.js +15 -12
- package/dist/core/coding-queue.d.ts +3 -0
- package/dist/core/coding-queue.js +6 -2
- package/dist/core/coding-service-status.d.ts +11 -0
- package/dist/core/coding-service-status.js +17 -0
- package/dist/core/cost-report.d.ts +88 -0
- package/dist/core/cost-report.js +248 -0
- package/dist/core/dispatch.d.ts +42 -2
- package/dist/core/dispatch.js +144 -26
- package/dist/core/engine-native.js +17 -4
- package/dist/core/glob.d.ts +10 -0
- package/dist/core/glob.js +33 -0
- package/dist/core/host-events.d.ts +24 -1
- package/dist/core/host-events.js +341 -1
- package/dist/core/host-status.d.ts +19 -2
- package/dist/core/host-status.js +47 -30
- package/dist/core/issue-bridge.d.ts +60 -0
- package/dist/core/issue-bridge.js +189 -0
- package/dist/core/issue-dedupe.d.ts +70 -0
- package/dist/core/issue-dedupe.js +255 -0
- package/dist/core/issue-events.d.ts +42 -0
- package/dist/core/issue-events.js +155 -0
- package/dist/core/issue-status.d.ts +29 -0
- package/dist/core/issue-status.js +241 -0
- package/dist/core/issue-tracker-tools.d.ts +64 -0
- package/dist/core/issue-tracker-tools.js +850 -0
- package/dist/core/model-usage.d.ts +10 -0
- package/dist/core/model-usage.js +24 -0
- package/dist/core/reconciler.d.ts +8 -4
- package/dist/core/reconciler.js +15 -4
- package/dist/core/review-host-tools.js +10 -3
- package/dist/core/run-pricing.d.ts +61 -0
- package/dist/core/run-pricing.js +56 -0
- package/dist/core/runner.d.ts +5 -2
- package/dist/core/runner.js +178 -27
- package/dist/core/scheduler.d.ts +4 -1
- package/dist/core/scheduler.js +3 -2
- package/dist/core/self-defects.d.ts +80 -0
- package/dist/core/self-defects.js +180 -0
- package/dist/core/tool-names.js +3 -0
- package/dist/core/webhooks.d.ts +9 -1
- package/dist/core/webhooks.js +19 -1
- package/dist/env.js +6 -1
- package/dist/generated/prisma/browser.d.ts +66 -0
- package/dist/generated/prisma/client.d.ts +66 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +122 -52
- package/dist/generated/prisma/enums.d.ts +7 -0
- package/dist/generated/prisma/enums.js +6 -0
- package/dist/generated/prisma/internal/class.d.ts +99 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +826 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +135 -2
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +142 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -2
- package/dist/generated/prisma/models/Agent.d.ts +389 -1
- package/dist/generated/prisma/models/AgentIssueProject.d.ts +1838 -0
- package/dist/generated/prisma/models/AgentIssueProject.js +1 -0
- package/dist/generated/prisma/models/AgentRepository.d.ts +1 -1
- package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
- package/dist/generated/prisma/models/CodingProxySession.d.ts +73 -1
- package/dist/generated/prisma/models/CodingRun.d.ts +130 -1
- package/dist/generated/prisma/models/CodingRunServiceStatus.d.ts +1404 -0
- package/dist/generated/prisma/models/CodingRunServiceStatus.js +1 -0
- package/dist/generated/prisma/models/IssueFingerprint.d.ts +1183 -0
- package/dist/generated/prisma/models/IssueFingerprint.js +1 -0
- package/dist/generated/prisma/models/IssuePullRequest.d.ts +1255 -0
- package/dist/generated/prisma/models/IssuePullRequest.js +1 -0
- package/dist/generated/prisma/models/ModelCatalogEntry.d.ts +1322 -0
- package/dist/generated/prisma/models/ModelCatalogEntry.js +1 -0
- package/dist/generated/prisma/models/Run.d.ts +933 -1
- package/dist/generated/prisma/models/RunAttribution.d.ts +1259 -0
- package/dist/generated/prisma/models/RunAttribution.js +1 -0
- package/dist/generated/prisma/models/RunIssueStatus.d.ts +1199 -0
- package/dist/generated/prisma/models/RunIssueStatus.js +1 -0
- package/dist/generated/prisma/models/RunModelUsage.d.ts +1316 -0
- package/dist/generated/prisma/models/RunModelUsage.js +1 -0
- package/dist/generated/prisma/models/WorkItem.d.ts +1408 -0
- package/dist/generated/prisma/models/WorkItem.js +1 -0
- package/dist/generated/prisma/models.d.ts +9 -0
- package/dist/help-index.json +355 -16
- package/dist/import/neutral-schema.d.ts +16 -16
- package/dist/knowledge/check.d.ts +13 -0
- package/dist/knowledge/check.js +69 -0
- package/dist/knowledge/cli.d.ts +14 -0
- package/dist/knowledge/cli.js +67 -0
- package/dist/knowledge/concept.d.ts +54 -0
- package/dist/knowledge/concept.js +78 -0
- package/dist/knowledge/note.d.ts +11 -0
- package/dist/knowledge/note.js +39 -0
- package/dist/knowledge/relevance.d.ts +11 -0
- package/dist/knowledge/relevance.js +14 -0
- package/dist/knowledge/span-hash.d.ts +3 -0
- package/dist/knowledge/span-hash.js +16 -0
- package/dist/mcp/auth/access.d.ts +4 -2
- package/dist/mcp/auth/ownership.d.ts +9 -9
- package/dist/mcp/auth/resource-server.d.ts +3 -1
- package/dist/mcp/auth/resource-server.js +18 -3
- package/dist/mcp/auth/self-hosted/credentials.d.ts +3 -3
- package/dist/mcp/auth/self-hosted/session.d.ts +5 -5
- package/dist/mcp/context.d.ts +3 -0
- package/dist/mcp/host-events/deliveries.d.ts +9 -0
- package/dist/mcp/host-events/deliveries.js +17 -0
- package/dist/mcp/host-events/github-ingress.d.ts +4 -2
- package/dist/mcp/host-events/github-ingress.js +4 -13
- package/dist/mcp/host-events/jira-ingress.d.ts +29 -0
- package/dist/mcp/host-events/jira-ingress.js +92 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +87 -9
- package/dist/mcp/server.js +5 -2
- package/dist/mcp/tools/agents.js +74 -3
- package/dist/mcp/tools/cost-report.d.ts +8 -0
- package/dist/mcp/tools/cost-report.js +60 -0
- package/dist/mcp/tools/issue-projects.d.ts +2 -0
- package/dist/mcp/tools/issue-projects.js +238 -0
- package/dist/mcp/tools/model-catalog.d.ts +22 -0
- package/dist/mcp/tools/model-catalog.js +423 -0
- package/dist/mcp/tools/repositories.js +2 -1
- package/dist/mcp/tools/tools.d.ts +2 -2
- package/dist/mcp/tools/trigger.js +33 -5
- package/dist/mcp/transport/streamable-http.d.ts +5 -0
- package/dist/mcp/transport/streamable-http.js +23 -1
- package/dist/mcp/webhooks/ingress.d.ts +2 -1
- package/dist/mcp/webhooks/ingress.js +9 -2
- package/dist/providers/auth/self-hosted.d.ts +8 -1
- package/dist/providers/auth/self-hosted.js +39 -2
- package/dist/providers/coding-proxy/memory-ledger.d.ts +1 -1
- package/dist/providers/coding-proxy/memory-ledger.js +10 -1
- package/dist/providers/coding-proxy/metering.d.ts +2 -1
- package/dist/providers/coding-proxy/metering.js +13 -2
- package/dist/providers/coding-proxy/prisma-ledger.js +59 -6
- package/dist/providers/coding-proxy/proxy.d.ts +12 -2
- package/dist/providers/coding-proxy/proxy.js +92 -30
- package/dist/providers/coding-proxy/types.d.ts +18 -1
- package/dist/providers/coding-proxy/types.js +12 -1
- package/dist/providers/engine/types.d.ts +19 -0
- package/dist/providers/executor/composition.js +9 -1
- package/dist/providers/executor/container.d.ts +30 -2
- package/dist/providers/executor/container.js +98 -17
- package/dist/providers/executor/dbos.d.ts +2 -0
- package/dist/providers/executor/dbos.js +7 -5
- package/dist/providers/executor/routing.d.ts +6 -0
- package/dist/providers/executor/routing.js +5 -0
- package/dist/providers/executor/types.d.ts +12 -0
- package/dist/providers/issue-tracker/adf.d.ts +31 -0
- package/dist/providers/issue-tracker/adf.js +181 -0
- package/dist/providers/issue-tracker/index.d.ts +5 -0
- package/dist/providers/issue-tracker/index.js +12 -0
- package/dist/providers/issue-tracker/jira-client.d.ts +41 -0
- package/dist/providers/issue-tracker/jira-client.js +151 -0
- package/dist/providers/issue-tracker/jira-events.d.ts +3 -0
- package/dist/providers/issue-tracker/jira-events.js +98 -0
- package/dist/providers/issue-tracker/jira.d.ts +116 -0
- package/dist/providers/issue-tracker/jira.js +502 -0
- package/dist/providers/issue-tracker/types.d.ts +269 -0
- package/dist/providers/issue-tracker/types.js +16 -0
- package/dist/providers/jobs/docker.d.ts +5 -1
- package/dist/providers/jobs/docker.js +61 -33
- package/dist/providers/jobs/kubernetes.d.ts +3 -0
- package/dist/providers/jobs/kubernetes.js +44 -4
- package/dist/providers/jobs/service-state.d.ts +22 -0
- package/dist/providers/jobs/service-state.js +17 -0
- package/dist/providers/llm/anthropic.d.ts +3 -3
- package/dist/providers/llm/anthropic.js +3 -9
- package/dist/providers/llm/bedrock.d.ts +3 -3
- package/dist/providers/llm/bedrock.js +3 -9
- package/dist/providers/llm/catalog-lookup.d.ts +10 -0
- package/dist/providers/llm/catalog-lookup.js +15 -0
- package/dist/providers/llm/catalog-shipped.d.ts +18 -0
- package/dist/providers/llm/catalog-shipped.js +197 -0
- package/dist/providers/llm/catalog-store.d.ts +58 -0
- package/dist/providers/llm/catalog-store.js +138 -0
- package/dist/providers/llm/catalog-types.d.ts +66 -0
- package/dist/providers/llm/catalog-types.js +64 -0
- package/dist/providers/llm/catalog.d.ts +61 -0
- package/dist/providers/llm/catalog.js +147 -0
- package/dist/providers/llm/claude-provider.d.ts +10 -14
- package/dist/providers/llm/claude-provider.js +11 -6
- package/dist/providers/llm/index.d.ts +9 -6
- package/dist/providers/llm/index.js +8 -5
- package/dist/providers/llm/openai.d.ts +14 -5
- package/dist/providers/llm/openai.js +24 -14
- package/dist/providers/llm/pricing-core.d.ts +5 -3
- package/dist/providers/llm/registration.js +8 -12
- package/dist/providers/llm/routing.d.ts +18 -17
- package/dist/providers/llm/routing.js +40 -24
- package/dist/providers/review-host/github-events.js +47 -1
- package/dist/providers/review-host/github.js +7 -6
- package/dist/providers/review-host/types.d.ts +25 -0
- package/dist/providers/vcs/git.js +2 -22
- package/dist/providers/vcs/github.d.ts +20 -0
- package/dist/providers/vcs/github.js +28 -2
- package/dist/providers/vcs/types.d.ts +6 -0
- package/dist/quickstart/index.d.ts +8 -0
- package/dist/quickstart/index.js +34 -34
- package/dist/serve.js +8 -2
- package/dist/viewer/api-schema.d.ts +2757 -0
- package/dist/viewer/api-schema.js +165 -0
- package/dist/viewer/build-schemas.d.ts +2 -0
- package/dist/viewer/build-schemas.js +18 -0
- package/dist/viewer/event-bus.d.ts +38 -0
- package/dist/viewer/event-bus.js +232 -0
- package/dist/viewer/graph.d.ts +40 -0
- package/dist/viewer/graph.js +243 -0
- package/dist/viewer/http.d.ts +30 -0
- package/dist/viewer/http.js +133 -0
- package/dist/viewer/run-detail.d.ts +4 -0
- package/dist/viewer/run-detail.js +61 -0
- package/dist/wardby-bin.js +5 -0
- package/docs/README.md +10 -0
- package/docs/agent-recipes.md +383 -0
- package/docs/code-review-agents.md +29 -2
- package/docs/coding-agent-setup.md +3 -0
- package/docs/coding-worker-isolation.md +39 -5
- package/docs/getting-started-gke.md +28 -11
- package/docs/getting-started-identity-provider.md +49 -38
- package/docs/getting-started.md +14 -0
- package/docs/jira-agents.md +649 -0
- package/docs/knowledge.md +387 -0
- package/docs/models.md +221 -0
- package/docs/security-deployment.md +19 -9
- package/docs/viewer-api.md +142 -0
- package/help/admin-viewer.md +39 -0
- package/help/agent-recipes.md +173 -0
- package/help/architecture-agent.md +189 -0
- package/help/builder-agent.md +80 -0
- package/help/code-review-agents.md +6 -0
- package/help/cost-attribution.md +67 -0
- package/help/creating-agents.md +22 -0
- package/help/deploy-gke.md +6 -0
- package/help/errors/model-unavailable.md +63 -0
- package/help/getting-started.md +1 -0
- package/help/github.md +18 -0
- package/help/identity-and-access.md +8 -3
- package/help/jira.md +135 -0
- package/help/knowledge.md +47 -0
- package/help/models.md +90 -0
- package/help/operating-agents.md +7 -1
- package/help/troubleshooting/budgets.md +6 -0
- package/package.json +5 -2
- package/prisma/migrations/20260930000000_jira_issue_projects/migration.sql +34 -0
- package/prisma/migrations/20261001000000_jira_phase2_allowlists/migration.sql +3 -0
- package/prisma/migrations/20261001010000_jira_link_types_allowlist/migration.sql +2 -0
- package/prisma/migrations/20261002000000_jira_coding_bridge/migration.sql +28 -0
- package/prisma/migrations/20261002010000_jira_issue_creation/migration.sql +25 -0
- package/prisma/migrations/20261003000000_issue_cost_attribution/migration.sql +56 -0
- package/prisma/migrations/20261003010000_coding_run_service_status/migration.sql +23 -0
- package/prisma/migrations/20261003020000_viewer_notify/migration.sql +54 -0
- package/prisma/migrations/20261003030000_viewer_notify_fixes/migration.sql +47 -0
- package/prisma/migrations/20261003040000_viewer_indexes/migration.sql +12 -0
- package/prisma/migrations/20261004000000_model_catalog/migration.sql +26 -0
- package/prisma/schema.prisma +258 -2
- package/dist/mcp/tools/models.d.ts +0 -8
- package/dist/mcp/tools/models.js +0 -15
- package/dist/providers/llm/pricing-anthropic.d.ts +0 -14
- package/dist/providers/llm/pricing-anthropic.js +0 -48
- package/dist/providers/llm/pricing-bedrock-claude.d.ts +0 -20
- package/dist/providers/llm/pricing-bedrock-claude.js +0 -46
- package/dist/providers/llm/pricing.d.ts +0 -30
- package/dist/providers/llm/pricing.js +0 -74
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jira Cloud implementation of IssueTracker over REST v3. Rich text is ADF
|
|
3
|
+
* in and plain text out (adf.ts). The bot's comments carry a visible footer
|
|
4
|
+
* naming the agent (ADF has no hidden comments), which is how
|
|
5
|
+
* jira_edit_own_comment knows a comment is this agent's.
|
|
6
|
+
*/
|
|
7
|
+
import { adfToText, markdownToAdf } from "./adf.js";
|
|
8
|
+
import { IssueTrackerError, projectOf, } from "./types.js";
|
|
9
|
+
const ISSUE_FIELDS = "project,summary,description,status,issuetype,priority,labels,assignee,reporter,comment,attachment,issuelinks";
|
|
10
|
+
const MAX_DESCRIPTION = 20_000;
|
|
11
|
+
const MAX_LINKS = 20;
|
|
12
|
+
const MAX_COMMENT = 4_000;
|
|
13
|
+
/** An issue view lists (and jira_read_attachment can read) only the most recent this many attachments. */
|
|
14
|
+
const MAX_ATTACHMENTS = 20;
|
|
15
|
+
const META_PAGE_SIZE = 50;
|
|
16
|
+
const META_MAX_PAGES = 10;
|
|
17
|
+
/** Required fields wardby always supplies itself in a create call. */
|
|
18
|
+
const ALWAYS_SUPPLIED = new Set(["project", "issuetype"]);
|
|
19
|
+
const PERSONAL_ACCOUNT_MESSAGE = "wardby's Jira token belongs to a person's account; use a service account (see docs/jira-agents.md).";
|
|
20
|
+
export const agentFooter = (agentId) => `_wardby agent ${agentId}_`;
|
|
21
|
+
/** Whether rendered comment text ends with this agent's footer (as adfToText renders it: no markdown underscores). */
|
|
22
|
+
export const hasAgentFooter = (text, agentId) => {
|
|
23
|
+
const last = text.trimEnd().split("\n").pop() ?? "";
|
|
24
|
+
return last.trim() === `wardby agent ${agentId}`;
|
|
25
|
+
};
|
|
26
|
+
/** Whether rendered comment text ends with wardby's run-status footer (`_wardby run <id>_` as adfToText renders it). */
|
|
27
|
+
export const isStatusComment = (text) => {
|
|
28
|
+
const last = text.trimEnd().split("\n").pop() ?? "";
|
|
29
|
+
return /^wardby run \S+$/.test(last.trim());
|
|
30
|
+
};
|
|
31
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
32
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
33
|
+
const person = (v) => {
|
|
34
|
+
const p = obj(v);
|
|
35
|
+
return typeof p.accountId === "string" ? { accountId: p.accountId, displayName: str(p.displayName) } : null;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* One call's budget (TrackerCallOptions): each request gets what is left of `timeoutMs`, and awaits that
|
|
39
|
+
* are not requests (the cached identity) are abandoned at the deadline. Without `timeoutMs`, a no-op.
|
|
40
|
+
*/
|
|
41
|
+
function callBudget(opts = {}) {
|
|
42
|
+
const deadline = opts.timeoutMs === undefined ? undefined : Date.now() + opts.timeoutMs;
|
|
43
|
+
const timedOut = () => new IssueTrackerError("tracker_api_error", "The Jira call timed out.");
|
|
44
|
+
const left = () => {
|
|
45
|
+
if (deadline === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
const ms = deadline - Date.now();
|
|
48
|
+
if (ms <= 0)
|
|
49
|
+
throw timedOut();
|
|
50
|
+
return ms;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
request(extra = {}) {
|
|
54
|
+
const ms = left();
|
|
55
|
+
return {
|
|
56
|
+
...extra,
|
|
57
|
+
...(ms !== undefined ? { timeoutMs: ms } : {}),
|
|
58
|
+
...(opts.retryOn429 !== undefined ? { retryOn429: opts.retryOn429 } : {}),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
async within(p) {
|
|
62
|
+
const ms = left();
|
|
63
|
+
if (ms === undefined)
|
|
64
|
+
return p;
|
|
65
|
+
let timer;
|
|
66
|
+
try {
|
|
67
|
+
return await Promise.race([
|
|
68
|
+
p,
|
|
69
|
+
new Promise((_, reject) => {
|
|
70
|
+
timer = setTimeout(() => reject(timedOut()), ms);
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Jira reports locales as "en_US"; Accept-Language wants BCP-47 ("en-US"). Anything malformed keeps the default. */
|
|
81
|
+
function localeToLanguageTag(locale) {
|
|
82
|
+
if (typeof locale !== "string")
|
|
83
|
+
return "en-US";
|
|
84
|
+
const m = /^([a-z]{2,3})(?:[_-]([A-Za-z]{2}|\d{3}))?$/.exec(locale.trim());
|
|
85
|
+
return m ? (m[2] ? `${m[1]}-${m[2].toUpperCase()}` : m[1]) : "en-US";
|
|
86
|
+
}
|
|
87
|
+
const statusCategory = (v) => {
|
|
88
|
+
const key = str(obj(obj(v).statusCategory).key);
|
|
89
|
+
return key === "new" || key === "done" ? key : "indeterminate";
|
|
90
|
+
};
|
|
91
|
+
const isTextMime = (mime) => {
|
|
92
|
+
const m = mime.split(";")[0].trim().toLowerCase();
|
|
93
|
+
return (m.startsWith("text/") ||
|
|
94
|
+
m === "application/json" ||
|
|
95
|
+
m === "application/xml" ||
|
|
96
|
+
m === "application/x-ndjson" ||
|
|
97
|
+
m.endsWith("+json"));
|
|
98
|
+
};
|
|
99
|
+
export class JiraIssueTracker {
|
|
100
|
+
client;
|
|
101
|
+
siteUrl;
|
|
102
|
+
opts;
|
|
103
|
+
provider = "jira";
|
|
104
|
+
me = null;
|
|
105
|
+
constructor(client, siteUrl, opts = {}) {
|
|
106
|
+
this.client = client;
|
|
107
|
+
this.siteUrl = siteUrl;
|
|
108
|
+
this.opts = opts;
|
|
109
|
+
}
|
|
110
|
+
identity() {
|
|
111
|
+
const pending = (this.me ??= this.client.request("GET", "/rest/api/3/myself").then((me) => {
|
|
112
|
+
if (typeof me.accountId !== "string")
|
|
113
|
+
throw new IssueTrackerError("tracker_invalid_response");
|
|
114
|
+
this.client.setLanguage(localeToLanguageTag(me.locale));
|
|
115
|
+
return { accountId: me.accountId, displayName: str(me.displayName), accountType: str(me.accountType) };
|
|
116
|
+
}));
|
|
117
|
+
pending.catch(() => {
|
|
118
|
+
if (this.me === pending)
|
|
119
|
+
this.me = null;
|
|
120
|
+
});
|
|
121
|
+
return pending;
|
|
122
|
+
}
|
|
123
|
+
/** Refuses to act when the token belongs to a person; a failed identity() propagates and is not cached. */
|
|
124
|
+
async ready(budget = callBudget()) {
|
|
125
|
+
if ((await budget.within(this.identity())).accountType === "atlassian") {
|
|
126
|
+
throw new IssueTrackerError("tracker_permission_denied", PERSONAL_ACCOUNT_MESSAGE);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async botAccountId() {
|
|
130
|
+
return (await this.identity()).accountId;
|
|
131
|
+
}
|
|
132
|
+
/** Markdown to ADF, with this site's issue links (and the given projects' bare keys) as smart links. */
|
|
133
|
+
toAdf(markdown, issueKeyProjects) {
|
|
134
|
+
return markdownToAdf(markdown, { siteUrl: this.siteUrl, projectKeys: issueKeyProjects ?? [] });
|
|
135
|
+
}
|
|
136
|
+
issueUrl(key) {
|
|
137
|
+
return `${this.siteUrl}/browse/${key}`;
|
|
138
|
+
}
|
|
139
|
+
async getIssue(key, opts) {
|
|
140
|
+
const budget = callBudget(opts);
|
|
141
|
+
await this.ready(budget);
|
|
142
|
+
const [raw, bot] = await Promise.all([
|
|
143
|
+
this.client.request("GET", `/rest/api/3/issue/${key}?fields=${ISSUE_FIELDS}`, undefined, budget.request()),
|
|
144
|
+
budget.within(this.botAccountId()),
|
|
145
|
+
]);
|
|
146
|
+
const f = obj(raw.fields);
|
|
147
|
+
const commentBlock = obj(f.comment);
|
|
148
|
+
const all = Array.isArray(commentBlock.comments) ? commentBlock.comments.map(obj) : [];
|
|
149
|
+
// wardby's own status comments ("Working on it" / outcome) are not issue context and mislead agents; drop
|
|
150
|
+
// them (bot-authored only) before windowing so they do not crowd out real comments.
|
|
151
|
+
// maxComments 0 (e.g. dedupe's status check): convert no comment at all (slice(-0) would keep them all).
|
|
152
|
+
const wantComments = opts.maxComments > 0;
|
|
153
|
+
const visible = wantComments
|
|
154
|
+
? all.filter((c) => !(person(c.author)?.accountId === bot && isStatusComment(adfToText(c.body, Infinity))))
|
|
155
|
+
: all;
|
|
156
|
+
const recent = wantComments ? visible.slice(-opts.maxComments) : [];
|
|
157
|
+
const comments = recent.map((c) => {
|
|
158
|
+
const author = person(c.author);
|
|
159
|
+
const full = adfToText(c.body, Infinity);
|
|
160
|
+
const body = adfToText(c.body, MAX_COMMENT);
|
|
161
|
+
return {
|
|
162
|
+
id: str(c.id),
|
|
163
|
+
author,
|
|
164
|
+
created: str(c.created),
|
|
165
|
+
body,
|
|
166
|
+
byThisAgent: author?.accountId === bot && hasAgentFooter(full, opts.agentMarker),
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const total = typeof commentBlock.total === "number" ? commentBlock.total : all.length;
|
|
170
|
+
return {
|
|
171
|
+
key: str(raw.key),
|
|
172
|
+
projectKey: str(obj(f.project).key),
|
|
173
|
+
summary: str(f.summary),
|
|
174
|
+
description: adfToText(f.description, MAX_DESCRIPTION),
|
|
175
|
+
status: str(obj(f.status).name),
|
|
176
|
+
statusCategory: statusCategory(f.status),
|
|
177
|
+
issueType: str(obj(f.issuetype).name),
|
|
178
|
+
priority: str(obj(f.priority).name) || null,
|
|
179
|
+
labels: Array.isArray(f.labels) ? f.labels.filter((l) => typeof l === "string") : [],
|
|
180
|
+
assignee: person(f.assignee),
|
|
181
|
+
reporter: person(f.reporter),
|
|
182
|
+
url: this.issueUrl(str(raw.key)),
|
|
183
|
+
comments,
|
|
184
|
+
// True when non-status comments were omitted (the window cut some, or Jira returned only part of them).
|
|
185
|
+
commentsTruncated: visible.length > comments.length || total > all.length,
|
|
186
|
+
attachments: (Array.isArray(f.attachment) ? f.attachment : [])
|
|
187
|
+
.map(obj)
|
|
188
|
+
.filter((a) => str(a.id) !== "")
|
|
189
|
+
// Jira lists attachments oldest first; keep the most recent (a fresh log is usually the useful one).
|
|
190
|
+
.slice(-MAX_ATTACHMENTS)
|
|
191
|
+
.map((a) => ({
|
|
192
|
+
id: str(a.id),
|
|
193
|
+
filename: str(a.filename),
|
|
194
|
+
mimeType: str(a.mimeType),
|
|
195
|
+
size: typeof a.size === "number" ? a.size : 0,
|
|
196
|
+
})),
|
|
197
|
+
links: (Array.isArray(f.issuelinks) ? f.issuelinks : [])
|
|
198
|
+
.map(obj)
|
|
199
|
+
.flatMap((l) => {
|
|
200
|
+
const type = obj(l.type);
|
|
201
|
+
const [other, relation] = l.outwardIssue
|
|
202
|
+
? [obj(l.outwardIssue), str(type.outward)]
|
|
203
|
+
: [obj(l.inwardIssue), str(type.inward)];
|
|
204
|
+
const key = str(other.key);
|
|
205
|
+
if (!key)
|
|
206
|
+
return [];
|
|
207
|
+
const of = obj(other.fields);
|
|
208
|
+
return [
|
|
209
|
+
{ relation: relation || str(type.name), key, summary: str(of.summary), status: str(obj(of.status).name) },
|
|
210
|
+
];
|
|
211
|
+
})
|
|
212
|
+
.slice(0, MAX_LINKS),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async issueProject(key) {
|
|
216
|
+
await this.ready();
|
|
217
|
+
const raw = await this.client.request("GET", `/rest/api/3/issue/${key}?fields=project`);
|
|
218
|
+
const projectKey = obj(obj(raw.fields).project).key;
|
|
219
|
+
if (typeof projectKey !== "string" || !projectKey)
|
|
220
|
+
throw new IssueTrackerError("tracker_invalid_response");
|
|
221
|
+
return projectKey;
|
|
222
|
+
}
|
|
223
|
+
async snapshotIssue(key, opts = {}) {
|
|
224
|
+
await this.ready();
|
|
225
|
+
const fields = [
|
|
226
|
+
"summary",
|
|
227
|
+
"issuetype",
|
|
228
|
+
"project",
|
|
229
|
+
"parent",
|
|
230
|
+
...(this.opts.epicLinkField ? [this.opts.epicLinkField] : []),
|
|
231
|
+
];
|
|
232
|
+
const raw = await this.client.request("GET", `/rest/api/3/issue/${key}?fields=${fields.join(",")}`, undefined, { timeoutMs: opts.timeoutMs, retryOn429: opts.retryOn429 });
|
|
233
|
+
const f = obj(raw.fields);
|
|
234
|
+
const parentRaw = obj(f.parent);
|
|
235
|
+
const parentFields = obj(parentRaw.fields);
|
|
236
|
+
const epicLink = this.opts.epicLinkField ? f[this.opts.epicLinkField] : undefined;
|
|
237
|
+
// An epic sits at hierarchy level 1 whatever the site calls it; fall back to the name when Jira omits the level.
|
|
238
|
+
const parentType = obj(parentFields.issuetype);
|
|
239
|
+
const parentIsEpic = typeof parentType.hierarchyLevel === "number"
|
|
240
|
+
? parentType.hierarchyLevel === 1
|
|
241
|
+
: str(parentType.name).toLowerCase() === "epic";
|
|
242
|
+
const parent = typeof parentRaw.key === "string" && parentRaw.key
|
|
243
|
+
? {
|
|
244
|
+
key: parentRaw.key,
|
|
245
|
+
...(str(parentFields.summary) ? { title: str(parentFields.summary) } : {}),
|
|
246
|
+
kind: parentIsEpic ? "epic" : "parent",
|
|
247
|
+
}
|
|
248
|
+
: typeof epicLink === "string" && epicLink
|
|
249
|
+
? { key: epicLink, kind: "epic" }
|
|
250
|
+
: undefined;
|
|
251
|
+
return {
|
|
252
|
+
key,
|
|
253
|
+
...(str(f.summary) ? { title: str(f.summary) } : {}),
|
|
254
|
+
...(str(obj(f.issuetype).name) ? { type: str(obj(f.issuetype).name) } : {}),
|
|
255
|
+
url: this.issueUrl(key),
|
|
256
|
+
scopeKey: str(obj(f.project).key) || projectOf(key),
|
|
257
|
+
...(parent ? { parent } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async search(jql, opts) {
|
|
261
|
+
await this.ready();
|
|
262
|
+
const r = await this.client.request("POST", "/rest/api/3/search/jql", { jql, maxResults: opts.maxResults, fields: ["summary", "status", "issuetype", "updated"] }, { timeoutMs: opts.timeoutMs, retryOn429: opts.retryOn429 });
|
|
263
|
+
const issues = (Array.isArray(r.issues) ? r.issues : []).map(obj).map((i) => {
|
|
264
|
+
const f = obj(i.fields);
|
|
265
|
+
return {
|
|
266
|
+
key: str(i.key),
|
|
267
|
+
summary: str(f.summary),
|
|
268
|
+
status: str(obj(f.status).name),
|
|
269
|
+
issueType: str(obj(f.issuetype).name),
|
|
270
|
+
updated: str(f.updated),
|
|
271
|
+
url: this.issueUrl(str(i.key)),
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
return { issues, truncated: typeof r.nextPageToken === "string" };
|
|
275
|
+
}
|
|
276
|
+
async matchesJql(key, jql, opts = {}) {
|
|
277
|
+
await this.ready();
|
|
278
|
+
const r = await this.search(`issuekey = ${key} AND (${jql})`, { maxResults: 1, ...opts });
|
|
279
|
+
return r.issues.length > 0;
|
|
280
|
+
}
|
|
281
|
+
async comment(key, input, opts) {
|
|
282
|
+
const budget = callBudget(opts);
|
|
283
|
+
await this.ready(budget);
|
|
284
|
+
const r = await this.client.request("POST", `/rest/api/3/issue/${key}/comment`, {
|
|
285
|
+
body: this.toAdf(input.markdown, input.issueKeyProjects),
|
|
286
|
+
...(input.visibilityRole ? { visibility: { type: "role", value: input.visibilityRole } } : {}),
|
|
287
|
+
}, budget.request());
|
|
288
|
+
if (typeof r.id !== "string")
|
|
289
|
+
throw new IssueTrackerError("tracker_invalid_response");
|
|
290
|
+
return { id: r.id, url: `${this.issueUrl(key)}?focusedCommentId=${r.id}` };
|
|
291
|
+
}
|
|
292
|
+
async editComment(key, commentId, input) {
|
|
293
|
+
await this.ready();
|
|
294
|
+
await this.client.request("PUT", `/rest/api/3/issue/${key}/comment/${commentId}`, {
|
|
295
|
+
body: this.toAdf(input.markdown, input.issueKeyProjects),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async readComment(key, commentId) {
|
|
299
|
+
await this.ready();
|
|
300
|
+
try {
|
|
301
|
+
const c = await this.client.request("GET", `/rest/api/3/issue/${key}/comment/${commentId}`);
|
|
302
|
+
return { authorId: person(c.author)?.accountId ?? null, body: adfToText(c.body, Infinity) };
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (err instanceof IssueTrackerError && err.code === "tracker_not_found")
|
|
306
|
+
return null;
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async transitions(key) {
|
|
311
|
+
await this.ready();
|
|
312
|
+
const r = await this.client.request("GET", `/rest/api/3/issue/${key}/transitions`);
|
|
313
|
+
return (Array.isArray(r.transitions) ? r.transitions : []).map(obj).map((t) => ({
|
|
314
|
+
id: str(t.id),
|
|
315
|
+
name: str(t.name),
|
|
316
|
+
toStatus: str(obj(t.to).name),
|
|
317
|
+
toCategory: str(obj(obj(t.to).statusCategory).key),
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
async transitionTo(key, toStatus) {
|
|
321
|
+
await this.ready();
|
|
322
|
+
const wanted = toStatus.trim().toLowerCase();
|
|
323
|
+
const match = (await this.transitions(key)).find((t) => t.toStatus.toLowerCase() === wanted);
|
|
324
|
+
if (!match)
|
|
325
|
+
throw new IssueTrackerError("tracker_invalid_request", `No transition to "${toStatus}" is available from this issue's current status.`);
|
|
326
|
+
try {
|
|
327
|
+
await this.client.request("POST", `/rest/api/3/issue/${key}/transitions`, { transition: { id: match.id } }, { allowEmpty: true });
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
if (err instanceof IssueTrackerError && err.code === "tracker_invalid_request")
|
|
331
|
+
throw new IssueTrackerError("tracker_invalid_request", "This transition needs fields wardby can't fill (a transition screen); do it in Jira.");
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
return { transitionId: match.id, toStatus: match.toStatus };
|
|
335
|
+
}
|
|
336
|
+
async editableFields(key) {
|
|
337
|
+
await this.ready();
|
|
338
|
+
const r = await this.client.request("GET", `/rest/api/3/issue/${key}/editmeta`);
|
|
339
|
+
return Object.keys(obj(r.fields));
|
|
340
|
+
}
|
|
341
|
+
async editFields(key, fields) {
|
|
342
|
+
await this.ready();
|
|
343
|
+
const body = {};
|
|
344
|
+
for (const [id, value] of Object.entries(fields)) {
|
|
345
|
+
if (id === "components" && Array.isArray(value))
|
|
346
|
+
body[id] = value.map((name) => ({ name }));
|
|
347
|
+
else if (id === "priority" && typeof value === "string")
|
|
348
|
+
body[id] = { name: value };
|
|
349
|
+
else
|
|
350
|
+
body[id] = value;
|
|
351
|
+
}
|
|
352
|
+
await this.client.request("PUT", `/rest/api/3/issue/${key}`, { fields: body }, { allowEmpty: true });
|
|
353
|
+
}
|
|
354
|
+
async linkTypes(opts) {
|
|
355
|
+
const budget = callBudget(opts);
|
|
356
|
+
await this.ready(budget);
|
|
357
|
+
const r = await this.client.request("GET", "/rest/api/3/issueLinkType", undefined, budget.request());
|
|
358
|
+
return (Array.isArray(r.issueLinkTypes) ? r.issueLinkTypes : []).map(obj).map((t) => ({
|
|
359
|
+
name: str(t.name),
|
|
360
|
+
inward: str(t.inward),
|
|
361
|
+
outward: str(t.outward),
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
async linkIssues(input, opts) {
|
|
365
|
+
const budget = callBudget(opts);
|
|
366
|
+
await this.ready(budget);
|
|
367
|
+
await this.client.request("POST", "/rest/api/3/issueLink", {
|
|
368
|
+
type: { name: input.type },
|
|
369
|
+
inwardIssue: { key: input.inwardKey },
|
|
370
|
+
outwardIssue: { key: input.outwardKey },
|
|
371
|
+
}, budget.request({ allowEmpty: true }));
|
|
372
|
+
}
|
|
373
|
+
async addRemoteLink(key, input) {
|
|
374
|
+
await this.ready();
|
|
375
|
+
await this.client.request("POST", `/rest/api/3/issue/${key}/remotelink`, {
|
|
376
|
+
globalId: input.globalId,
|
|
377
|
+
object: { url: input.url, title: input.title, ...(input.status ? { status: input.status } : {}) },
|
|
378
|
+
}, { allowEmpty: true });
|
|
379
|
+
}
|
|
380
|
+
async getProperty(key, property) {
|
|
381
|
+
await this.ready();
|
|
382
|
+
try {
|
|
383
|
+
const r = await this.client.request("GET", `/rest/api/3/issue/${key}/properties/${encodeURIComponent(property)}`);
|
|
384
|
+
return r.value ?? null;
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
if (err instanceof IssueTrackerError && err.code === "tracker_not_found")
|
|
388
|
+
return null;
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async setProperty(key, property, value) {
|
|
393
|
+
await this.ready();
|
|
394
|
+
await this.client.request("PUT", `/rest/api/3/issue/${key}/properties/${encodeURIComponent(property)}`, value, {
|
|
395
|
+
allowEmpty: true,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
/** Pages a createmeta endpoint (startAt/maxResults/total), capped. */
|
|
399
|
+
async pagedMeta(path, listKey, budget) {
|
|
400
|
+
const out = [];
|
|
401
|
+
for (let page = 0, startAt = 0; page < META_MAX_PAGES; page++) {
|
|
402
|
+
const r = await this.client.request("GET", `${path}?startAt=${startAt}&maxResults=${META_PAGE_SIZE}`, undefined, budget.request());
|
|
403
|
+
const items = Array.isArray(r[listKey]) ? r[listKey].map(obj) : [];
|
|
404
|
+
out.push(...items);
|
|
405
|
+
startAt += items.length;
|
|
406
|
+
if (items.length === 0 || (typeof r.total === "number" ? startAt >= r.total : items.length < META_PAGE_SIZE))
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
async createMeta(projectKey, budget = callBudget()) {
|
|
412
|
+
await this.ready(budget);
|
|
413
|
+
const items = await this.pagedMeta(`/rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`, "issueTypes", budget);
|
|
414
|
+
return {
|
|
415
|
+
issueTypes: items
|
|
416
|
+
.filter((t) => str(t.id) !== "")
|
|
417
|
+
.map((t) => ({ id: str(t.id), name: str(t.name), subtask: t.subtask === true })),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
async fieldMeta(projectKey, issueTypeId, budget = callBudget()) {
|
|
421
|
+
await this.ready(budget);
|
|
422
|
+
const items = await this.pagedMeta(`/rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`, "fields", budget);
|
|
423
|
+
return items
|
|
424
|
+
.filter((f) => str(f.fieldId) !== "")
|
|
425
|
+
.map((f) => {
|
|
426
|
+
const allowed = Array.isArray(f.allowedValues)
|
|
427
|
+
? f.allowedValues.map((v) => str(obj(v).name) || str(obj(v).value) || str(obj(v).id)).filter(Boolean)
|
|
428
|
+
: [];
|
|
429
|
+
return {
|
|
430
|
+
fieldId: str(f.fieldId),
|
|
431
|
+
name: str(f.name),
|
|
432
|
+
required: f.required === true,
|
|
433
|
+
hasDefault: f.hasDefaultValue === true,
|
|
434
|
+
...(allowed.length > 0 ? { allowedValues: allowed } : {}),
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
async createIssue(input, opts) {
|
|
439
|
+
const budget = callBudget(opts);
|
|
440
|
+
await this.ready(budget);
|
|
441
|
+
const wanted = input.issueType.trim().toLowerCase();
|
|
442
|
+
const type = (await this.createMeta(input.projectKey, budget)).issueTypes.find((t) => t.name.toLowerCase() === wanted);
|
|
443
|
+
if (!type)
|
|
444
|
+
throw new IssueTrackerError("tracker_invalid_request", `Issue type "${input.issueType}" is not available in project ${input.projectKey}.`);
|
|
445
|
+
const fields = {};
|
|
446
|
+
for (const [id, value] of Object.entries(input.customFields ?? {})) {
|
|
447
|
+
if (!/^customfield_\d+$/.test(id))
|
|
448
|
+
throw new IssueTrackerError("tracker_invalid_request", `customFields may only set customfield_N fields; "${id}" is not allowed.`);
|
|
449
|
+
fields[id] = value;
|
|
450
|
+
}
|
|
451
|
+
if (type.subtask && !input.parentKey)
|
|
452
|
+
throw new IssueTrackerError("tracker_invalid_request", `Sub-task issues need a parent (${type.name}).`);
|
|
453
|
+
// Core fields are applied last so nothing above can override them.
|
|
454
|
+
Object.assign(fields, {
|
|
455
|
+
project: { key: input.projectKey },
|
|
456
|
+
issuetype: { id: type.id },
|
|
457
|
+
summary: input.summary,
|
|
458
|
+
description: this.toAdf(input.descriptionMarkdown, input.issueKeyProjects),
|
|
459
|
+
});
|
|
460
|
+
if (input.labels)
|
|
461
|
+
fields.labels = input.labels;
|
|
462
|
+
if (input.priority)
|
|
463
|
+
fields.priority = { name: input.priority };
|
|
464
|
+
if (input.components)
|
|
465
|
+
fields.components = input.components.map((name) => ({ name }));
|
|
466
|
+
if (input.parentKey)
|
|
467
|
+
fields.parent = { key: input.parentKey };
|
|
468
|
+
const missing = (await this.fieldMeta(input.projectKey, type.id, budget))
|
|
469
|
+
.filter((f) => f.required && !f.hasDefault && !ALWAYS_SUPPLIED.has(f.fieldId) && fields[f.fieldId] === undefined)
|
|
470
|
+
.map((f) => f.name || f.fieldId);
|
|
471
|
+
if (missing.length > 0)
|
|
472
|
+
throw new IssueTrackerError("tracker_invalid_request", `Required fields are missing for ${type.name} in ${input.projectKey}: ${missing.join(", ")}.`);
|
|
473
|
+
const properties = Object.entries(input.properties ?? {}).map(([key, value]) => ({ key, value }));
|
|
474
|
+
const r = await this.client.request("POST", "/rest/api/3/issue", { fields, ...(properties.length > 0 ? { properties } : {}) }, budget.request());
|
|
475
|
+
if (typeof r.key !== "string" || !r.key)
|
|
476
|
+
throw new IssueTrackerError("tracker_invalid_response");
|
|
477
|
+
return { key: r.key, url: this.issueUrl(r.key) };
|
|
478
|
+
}
|
|
479
|
+
async readAttachmentText(id, maxBytes) {
|
|
480
|
+
await this.ready();
|
|
481
|
+
const meta = await this.client.request("GET", `/rest/api/3/attachment/${encodeURIComponent(id)}`);
|
|
482
|
+
const filename = str(meta.filename);
|
|
483
|
+
const mimeType = str(meta.mimeType);
|
|
484
|
+
if (!isTextMime(mimeType))
|
|
485
|
+
throw new IssueTrackerError("tracker_invalid_request", `Attachment "${filename}" is ${mimeType || "of unknown type"}; only text-like attachments can be read.`);
|
|
486
|
+
const size = typeof meta.size === "number" ? meta.size : null;
|
|
487
|
+
const cap = Math.max(1, Math.floor(maxBytes));
|
|
488
|
+
if (size === 0)
|
|
489
|
+
return { filename, mimeType, text: "", truncated: false };
|
|
490
|
+
// redirect=false: Jira serves the bytes itself (206 with Range) instead of a 303 to the media host.
|
|
491
|
+
// A Range reaching past the end of the file is answered with a Content-Length of the requested
|
|
492
|
+
// length and the connection is then cut short, so only ask for a range when the file is larger.
|
|
493
|
+
const whole = size !== null && size <= cap;
|
|
494
|
+
const bytes = await this.client.requestBytes(`/rest/api/3/attachment/content/${encodeURIComponent(id)}?redirect=false`, whole ? { maxBytes: size, range: false } : { maxBytes: cap });
|
|
495
|
+
return {
|
|
496
|
+
filename,
|
|
497
|
+
mimeType,
|
|
498
|
+
text: new TextDecoder("utf-8", { fatal: false }).decode(bytes),
|
|
499
|
+
truncated: size === null ? bytes.length >= cap : size > cap,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|