@codyswann/lisa 4.27.6 → 4.27.8
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/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +507 -11
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +102 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/project-config-nightly-e2e.d.ts +21 -0
- package/dist/core/project-config-nightly-e2e.d.ts.map +1 -0
- package/dist/core/project-config-nightly-e2e.js +43 -0
- package/dist/core/project-config-nightly-e2e.js.map +1 -0
- package/dist/core/project-config.d.ts +4 -0
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +3 -0
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +56 -12
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +180 -19
- package/expo/create-only/.github/nightly-e2e-policy.json +16 -0
- package/expo/create-only/.github/workflows/nightly-e2e-health.yml +33 -8
- package/expo/create-only/.github/workflows/nightly-e2e-tracking.yml +30 -0
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-exploratory-qa/SKILL.md +10 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-intake/SKILL.md +18 -13
- package/plugins/lisa/.codex-plugin/skills/lisa-learnings-audit/SKILL.md +13 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +3 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-monitor/SKILL.md +10 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-project-ideation/SKILL.md +10 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +10 -6
- package/plugins/lisa/hooks/block-direct-issue-create.sh +507 -11
- package/plugins/lisa/skills/lisa-exploratory-qa/SKILL.md +10 -6
- package/plugins/lisa/skills/lisa-intake/SKILL.md +18 -13
- package/plugins/lisa/skills/lisa-learnings-audit/SKILL.md +13 -7
- package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +3 -1
- package/plugins/lisa/skills/lisa-monitor/SKILL.md +10 -6
- package/plugins/lisa/skills/lisa-project-ideation/SKILL.md +10 -6
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +10 -6
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +507 -11
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-exploratory-qa/SKILL.md +10 -6
- package/plugins/lisa-agy/skills/lisa-intake/SKILL.md +18 -13
- package/plugins/lisa-agy/skills/lisa-learnings-audit/SKILL.md +13 -7
- package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +3 -1
- package/plugins/lisa-agy/skills/lisa-monitor/SKILL.md +10 -6
- package/plugins/lisa-agy/skills/lisa-project-ideation/SKILL.md +10 -6
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +10 -6
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +507 -11
- package/plugins/lisa-copilot/skills/lisa-exploratory-qa/SKILL.md +10 -6
- package/plugins/lisa-copilot/skills/lisa-intake/SKILL.md +18 -13
- package/plugins/lisa-copilot/skills/lisa-learnings-audit/SKILL.md +13 -7
- package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +3 -1
- package/plugins/lisa-copilot/skills/lisa-monitor/SKILL.md +10 -6
- package/plugins/lisa-copilot/skills/lisa-project-ideation/SKILL.md +10 -6
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +10 -6
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +507 -11
- package/plugins/lisa-cursor/skills/lisa-exploratory-qa/SKILL.md +10 -6
- package/plugins/lisa-cursor/skills/lisa-intake/SKILL.md +18 -13
- package/plugins/lisa-cursor/skills/lisa-learnings-audit/SKILL.md +13 -7
- package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +3 -1
- package/plugins/lisa-cursor/skills/lisa-monitor/SKILL.md +10 -6
- package/plugins/lisa-cursor/skills/lisa-project-ideation/SKILL.md +10 -6
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +10 -6
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-direct-issue-create.sh +507 -11
- package/plugins/src/base/skills/lisa-exploratory-qa/SKILL.md +10 -6
- package/plugins/src/base/skills/lisa-intake/SKILL.md +18 -13
- package/plugins/src/base/skills/lisa-learnings-audit/SKILL.md +13 -7
- package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +3 -1
- package/plugins/src/base/skills/lisa-monitor/SKILL.md +10 -6
- package/plugins/src/base/skills/lisa-project-ideation/SKILL.md +10 -6
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +10 -6
- package/scripts/two-channel-couplings.json +70 -5
- package/typescript/copy-overwrite/.prettierignore +7 -0
- package/typescript/copy-overwrite/scripts/nightly-e2e-provider-action.mjs +70 -0
- package/typescript/copy-overwrite/scripts/nightly-e2e-provider-github-jira.mjs +296 -0
- package/typescript/copy-overwrite/scripts/nightly-e2e-provider-linear-sentry.mjs +283 -0
- package/typescript/copy-overwrite/scripts/nightly-e2e-provider-support.mjs +55 -0
- package/typescript/copy-overwrite/scripts/nightly-e2e-tracking-config.mjs +149 -0
- package/typescript/copy-overwrite/scripts/reconcile-nightly-e2e-tracking-cli.mjs +56 -0
- package/typescript/copy-overwrite/scripts/reconcile-nightly-e2e-tracking.mjs +208 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
/** GitHub and Jira adapters for combined nightly-E2E tracking. */
|
|
5
|
+
import { CONDITION_MARKER } from "./reconcile-nightly-e2e-tracking.mjs";
|
|
6
|
+
import { readback, refuse, required } from "./nightly-e2e-provider-support.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_GITHUB_PAGES = 10;
|
|
9
|
+
const MAX_JIRA_PAGES = 3;
|
|
10
|
+
|
|
11
|
+
/** Return whether a GitHub issue has the strict REST identity shape. */
|
|
12
|
+
function isGitHubIssue(issue) {
|
|
13
|
+
return (
|
|
14
|
+
issue !== null &&
|
|
15
|
+
typeof issue === "object" &&
|
|
16
|
+
Number.isSafeInteger(issue.number) &&
|
|
17
|
+
issue.number > 0 &&
|
|
18
|
+
typeof issue.node_id === "string" &&
|
|
19
|
+
issue.node_id !== "" &&
|
|
20
|
+
(typeof issue.body === "string" || issue.body === null)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build GitHub's issue adapter over the injected JSON transport. */
|
|
25
|
+
export function githubAdapter(input) {
|
|
26
|
+
const destination = "github";
|
|
27
|
+
const repository = required(input, destination, "GITHUB_REPOSITORY");
|
|
28
|
+
const token = required(input, destination, "PROVIDER_TOKEN");
|
|
29
|
+
const [owner, repo, extra] = repository.split("/");
|
|
30
|
+
if (!owner || !repo || extra) refuse(destination, "configuration");
|
|
31
|
+
const base = `https://api.github.com/repos/${repository}/issues`;
|
|
32
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
33
|
+
const numbers = new Map();
|
|
34
|
+
const list = async (operation = "list") => {
|
|
35
|
+
const issues = [];
|
|
36
|
+
for (let page = 1; page <= MAX_GITHUB_PAGES; page += 1) {
|
|
37
|
+
const raw = await input.request({
|
|
38
|
+
operation,
|
|
39
|
+
url: `${base}?state=open&per_page=100&page=${page}`,
|
|
40
|
+
options: { method: "GET", headers },
|
|
41
|
+
});
|
|
42
|
+
if (!Array.isArray(raw) || !raw.every(isGitHubIssue)) {
|
|
43
|
+
refuse(destination, operation, "malformed response");
|
|
44
|
+
}
|
|
45
|
+
issues.push(...raw);
|
|
46
|
+
if (raw.length < 100) break;
|
|
47
|
+
if (page === MAX_GITHUB_PAGES) {
|
|
48
|
+
refuse(destination, operation, "result overflow");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return issues
|
|
52
|
+
.filter(
|
|
53
|
+
issue => !issue.pull_request && issue.body?.includes(CONDITION_MARKER)
|
|
54
|
+
)
|
|
55
|
+
.map(issue => {
|
|
56
|
+
const id = issue.node_id ?? String(issue.number);
|
|
57
|
+
numbers.set(id, issue.number);
|
|
58
|
+
return { id, marker: CONDITION_MARKER, pinned: false };
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
return githubActions(input, { destination, base, headers, numbers, list });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Materialize GitHub's write operations around the shared readback. */
|
|
65
|
+
function githubActions(input, context) {
|
|
66
|
+
const { destination, base, headers, numbers, list } = context;
|
|
67
|
+
return {
|
|
68
|
+
async list() {
|
|
69
|
+
return list();
|
|
70
|
+
},
|
|
71
|
+
async create(draft) {
|
|
72
|
+
const issue = await input.request({
|
|
73
|
+
operation: "create",
|
|
74
|
+
url: base,
|
|
75
|
+
options: {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers,
|
|
78
|
+
body: JSON.stringify({ title: draft.title, body: draft.body }),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const id = issue?.node_id ?? String(issue?.number ?? "");
|
|
82
|
+
if (!id) refuse(destination, "create", "missing issue identity");
|
|
83
|
+
return readback(destination, list, id, true);
|
|
84
|
+
},
|
|
85
|
+
async refresh(id, draft) {
|
|
86
|
+
const number = numbers.get(id);
|
|
87
|
+
if (!number) refuse(destination, "refresh", "unknown issue identity");
|
|
88
|
+
await input.request({
|
|
89
|
+
operation: "refresh",
|
|
90
|
+
url: `${base}/${number}`,
|
|
91
|
+
options: {
|
|
92
|
+
method: "PATCH",
|
|
93
|
+
headers,
|
|
94
|
+
body: JSON.stringify({ title: draft.title, body: draft.body }),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return readback(destination, list, id, true);
|
|
98
|
+
},
|
|
99
|
+
async close(id) {
|
|
100
|
+
const number = numbers.get(id);
|
|
101
|
+
if (!number) refuse(destination, "close", "unknown issue identity");
|
|
102
|
+
await input.request({
|
|
103
|
+
operation: "close",
|
|
104
|
+
url: `${base}/${number}`,
|
|
105
|
+
options: { method: "PATCH", headers, body: '{"state":"closed"}' },
|
|
106
|
+
});
|
|
107
|
+
await readback(destination, list, id, false);
|
|
108
|
+
},
|
|
109
|
+
async pin(id) {
|
|
110
|
+
await githubPin(input, headers, "pin", id);
|
|
111
|
+
await readback(destination, list, id, true);
|
|
112
|
+
},
|
|
113
|
+
async unpin(id) {
|
|
114
|
+
await githubPin(input, headers, "unpin", id);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Invoke one GitHub pin lifecycle mutation. */
|
|
120
|
+
async function githubPin(input, headers, operation, id) {
|
|
121
|
+
const field = operation === "pin" ? "pinIssue" : "unpinIssue";
|
|
122
|
+
const query =
|
|
123
|
+
operation === "pin"
|
|
124
|
+
? "mutation($id:ID!){pinIssue(input:{issueId:$id}){issue{id}}}"
|
|
125
|
+
: "mutation($id:ID!){unpinIssue(input:{issueId:$id}){issue{id}}}";
|
|
126
|
+
const raw = await input.request({
|
|
127
|
+
operation,
|
|
128
|
+
url: "https://api.github.com/graphql",
|
|
129
|
+
options: {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers,
|
|
132
|
+
body: JSON.stringify({ query, variables: { id } }),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const errorsValid =
|
|
136
|
+
raw?.errors === undefined ||
|
|
137
|
+
(Array.isArray(raw.errors) && raw.errors.length === 0);
|
|
138
|
+
if (
|
|
139
|
+
!errorsValid ||
|
|
140
|
+
raw?.data === null ||
|
|
141
|
+
typeof raw?.data !== "object" ||
|
|
142
|
+
raw.data[field]?.issue?.id !== id
|
|
143
|
+
) {
|
|
144
|
+
refuse("github", operation, "issue identity authority");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Read all bounded Jira search pages through the enhanced-search API. */
|
|
149
|
+
async function jiraSearch(input, operation, base, headers, jql) {
|
|
150
|
+
const issues = [];
|
|
151
|
+
const tokens = new Set();
|
|
152
|
+
let nextPageToken;
|
|
153
|
+
for (let page = 1; page <= MAX_JIRA_PAGES; page += 1) {
|
|
154
|
+
const requestBody = {
|
|
155
|
+
jql,
|
|
156
|
+
fields: ["description"],
|
|
157
|
+
maxResults: 100,
|
|
158
|
+
...(nextPageToken === undefined ? {} : { nextPageToken }),
|
|
159
|
+
};
|
|
160
|
+
const raw = await input.request({
|
|
161
|
+
operation,
|
|
162
|
+
url: `${base}/rest/api/3/search/jql`,
|
|
163
|
+
options: {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers,
|
|
166
|
+
body: JSON.stringify(requestBody),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
if (!Array.isArray(raw?.issues) || typeof raw.isLast !== "boolean") {
|
|
170
|
+
refuse("jira", operation, "malformed response");
|
|
171
|
+
}
|
|
172
|
+
issues.push(...raw.issues);
|
|
173
|
+
if (raw.isLast) break;
|
|
174
|
+
if (
|
|
175
|
+
typeof raw.nextPageToken !== "string" ||
|
|
176
|
+
raw.nextPageToken === "" ||
|
|
177
|
+
tokens.has(raw.nextPageToken) ||
|
|
178
|
+
page === MAX_JIRA_PAGES
|
|
179
|
+
) {
|
|
180
|
+
refuse("jira", operation, "pagination authority");
|
|
181
|
+
}
|
|
182
|
+
tokens.add(raw.nextPageToken);
|
|
183
|
+
nextPageToken = raw.nextPageToken;
|
|
184
|
+
}
|
|
185
|
+
return issues;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Build Jira's issue adapter over the injected JSON transport. */
|
|
189
|
+
export function jiraAdapter(input) {
|
|
190
|
+
const destination = "jira";
|
|
191
|
+
const base = required(input, destination, "JIRA_BASE_URL").replace(
|
|
192
|
+
/\/$/u,
|
|
193
|
+
""
|
|
194
|
+
);
|
|
195
|
+
const email = required(input, destination, "JIRA_USER_EMAIL");
|
|
196
|
+
const project = required(input, destination, "JIRA_PROJECT_KEY");
|
|
197
|
+
const token = required(input, destination, "PROVIDER_TOKEN");
|
|
198
|
+
const credentials = Buffer.from(`${email}:${token}`).toString("base64");
|
|
199
|
+
const headers = {
|
|
200
|
+
Authorization: `Basic ${credentials}`,
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
};
|
|
203
|
+
const list = async (operation = "list") => {
|
|
204
|
+
const marker = JSON.stringify(`"${CONDITION_MARKER}"`);
|
|
205
|
+
const jql =
|
|
206
|
+
`project = ${JSON.stringify(project)} AND statusCategory != Done AND ` +
|
|
207
|
+
`description ~ ${marker}`;
|
|
208
|
+
const issues = await jiraSearch(input, operation, base, headers, jql);
|
|
209
|
+
return issues
|
|
210
|
+
.filter(issue => issue.fields?.description?.includes(CONDITION_MARKER))
|
|
211
|
+
.map(issue => ({
|
|
212
|
+
id: issue.key,
|
|
213
|
+
marker: CONDITION_MARKER,
|
|
214
|
+
pinned: false,
|
|
215
|
+
}));
|
|
216
|
+
};
|
|
217
|
+
const createFields = draft => ({
|
|
218
|
+
project: { key: project },
|
|
219
|
+
summary: draft.title,
|
|
220
|
+
description: draft.body,
|
|
221
|
+
issuetype: { name: "Bug" },
|
|
222
|
+
labels: ["nightly-e2e", "automation"],
|
|
223
|
+
});
|
|
224
|
+
const updateFields = draft => ({
|
|
225
|
+
summary: draft.title,
|
|
226
|
+
description: draft.body,
|
|
227
|
+
});
|
|
228
|
+
return jiraActions(input, {
|
|
229
|
+
destination,
|
|
230
|
+
base,
|
|
231
|
+
headers,
|
|
232
|
+
list,
|
|
233
|
+
createFields,
|
|
234
|
+
updateFields,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Materialize Jira's write operations around the shared readback. */
|
|
239
|
+
function jiraActions(input, context) {
|
|
240
|
+
const { destination, base, headers, list, createFields, updateFields } =
|
|
241
|
+
context;
|
|
242
|
+
return {
|
|
243
|
+
async list() {
|
|
244
|
+
return list();
|
|
245
|
+
},
|
|
246
|
+
async create(draft) {
|
|
247
|
+
const issue = await input.request({
|
|
248
|
+
operation: "create",
|
|
249
|
+
url: `${base}/rest/api/2/issue`,
|
|
250
|
+
options: {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers,
|
|
253
|
+
body: JSON.stringify({ fields: createFields(draft) }),
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
if (!issue?.key) refuse(destination, "create", "missing issue identity");
|
|
257
|
+
return readback(destination, list, issue.key, true);
|
|
258
|
+
},
|
|
259
|
+
async refresh(id, draft) {
|
|
260
|
+
await input.request({
|
|
261
|
+
operation: "refresh",
|
|
262
|
+
url: `${base}/rest/api/2/issue/${id}`,
|
|
263
|
+
options: {
|
|
264
|
+
method: "PUT",
|
|
265
|
+
headers,
|
|
266
|
+
body: JSON.stringify({ fields: updateFields(draft) }),
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
return readback(destination, list, id, true);
|
|
270
|
+
},
|
|
271
|
+
async close(id) {
|
|
272
|
+
const url = `${base}/rest/api/2/issue/${id}/transitions`;
|
|
273
|
+
const raw = await input.request({
|
|
274
|
+
operation: "close",
|
|
275
|
+
url,
|
|
276
|
+
options: { headers },
|
|
277
|
+
});
|
|
278
|
+
const done = (raw?.transitions ?? []).filter(
|
|
279
|
+
item => item.to?.statusCategory?.key === "done"
|
|
280
|
+
);
|
|
281
|
+
if (done.length !== 1) {
|
|
282
|
+
refuse(destination, "close", "done transition unavailable");
|
|
283
|
+
}
|
|
284
|
+
await input.request({
|
|
285
|
+
operation: "close",
|
|
286
|
+
url,
|
|
287
|
+
options: {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers,
|
|
290
|
+
body: JSON.stringify({ transition: { id: done[0].id } }),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
await readback(destination, list, id, false);
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
/** Linear and Sentry adapters for combined nightly-E2E tracking. */
|
|
5
|
+
import { CONDITION_MARKER } from "./reconcile-nightly-e2e-tracking.mjs";
|
|
6
|
+
import { readback, refuse, required } from "./nightly-e2e-provider-support.mjs";
|
|
7
|
+
|
|
8
|
+
const MARKER_TAG = "lisa_nightly_e2e_condition";
|
|
9
|
+
|
|
10
|
+
/** Resolve one listed Sentry group through authenticated event authority. */
|
|
11
|
+
async function sentryListedGroup(input, operation, issue, headers) {
|
|
12
|
+
if (typeof issue?.id !== "string" || !issue.id) {
|
|
13
|
+
refuse("sentry", operation, "issue identity authority");
|
|
14
|
+
}
|
|
15
|
+
const raw = await input.request({
|
|
16
|
+
operation,
|
|
17
|
+
url: `https://sentry.io/api/0/issues/${issue.id}/events/latest/`,
|
|
18
|
+
options: { method: "GET", headers },
|
|
19
|
+
});
|
|
20
|
+
const markers = Array.isArray(raw?.tags)
|
|
21
|
+
? raw.tags.filter(tag => tag?.key === MARKER_TAG)
|
|
22
|
+
: [];
|
|
23
|
+
if (
|
|
24
|
+
raw?.groupID !== issue.id ||
|
|
25
|
+
markers.length !== 1 ||
|
|
26
|
+
markers[0].value !== CONDITION_MARKER
|
|
27
|
+
) {
|
|
28
|
+
refuse("sentry", operation, "issue marker authority");
|
|
29
|
+
}
|
|
30
|
+
return { id: issue.id, marker: markers[0].value, pinned: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Call Linear GraphQL and reject error-bearing or unsuccessful mutations. */
|
|
34
|
+
async function linearApi(input, operation, query, variables) {
|
|
35
|
+
const raw = await input.request({
|
|
36
|
+
operation,
|
|
37
|
+
url: "https://api.linear.app/graphql",
|
|
38
|
+
options: {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: required(input, "linear", "PROVIDER_TOKEN"),
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ query, variables }),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
if (raw?.errors?.length || !raw?.data) {
|
|
48
|
+
refuse("linear", operation, "GraphQL error");
|
|
49
|
+
}
|
|
50
|
+
const mutation = raw.data.issueCreate ?? raw.data.issueUpdate;
|
|
51
|
+
if (mutation && mutation.success !== true) {
|
|
52
|
+
refuse("linear", operation, "success false");
|
|
53
|
+
}
|
|
54
|
+
return raw.data;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build Linear's issue adapter over the injected JSON transport. */
|
|
58
|
+
export function linearAdapter(input) {
|
|
59
|
+
const key = required(input, "linear", "LINEAR_TEAM_KEY");
|
|
60
|
+
let team;
|
|
61
|
+
const list = async (operation = "list") => {
|
|
62
|
+
const data = await linearApi(
|
|
63
|
+
input,
|
|
64
|
+
operation,
|
|
65
|
+
"query($key:String!){teams(filter:{key:{eq:$key}}){nodes{id " +
|
|
66
|
+
"states{nodes{id type}} issues(first:100){nodes{id description " +
|
|
67
|
+
"state{type}}}}}}",
|
|
68
|
+
{ key }
|
|
69
|
+
);
|
|
70
|
+
const teams = data.teams?.nodes ?? [];
|
|
71
|
+
if (teams.length !== 1) {
|
|
72
|
+
refuse("linear", operation, "exact team unavailable");
|
|
73
|
+
}
|
|
74
|
+
[team] = teams;
|
|
75
|
+
return (team.issues?.nodes ?? [])
|
|
76
|
+
.filter(
|
|
77
|
+
issue =>
|
|
78
|
+
issue.description?.includes(CONDITION_MARKER) &&
|
|
79
|
+
!["completed", "canceled"].includes(issue.state?.type)
|
|
80
|
+
)
|
|
81
|
+
.map(issue => ({
|
|
82
|
+
id: issue.id,
|
|
83
|
+
marker: CONDITION_MARKER,
|
|
84
|
+
pinned: false,
|
|
85
|
+
}));
|
|
86
|
+
};
|
|
87
|
+
return linearActions(input, { list, team: () => team });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Materialize Linear's write operations around the shared readback. */
|
|
91
|
+
function linearActions(input, context) {
|
|
92
|
+
const { list, team } = context;
|
|
93
|
+
const mutate = async (operation, query, variables, id, present) => {
|
|
94
|
+
await linearApi(input, operation, query, variables);
|
|
95
|
+
return readback("linear", list, id, present);
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
async list() {
|
|
99
|
+
return list();
|
|
100
|
+
},
|
|
101
|
+
async create(draft) {
|
|
102
|
+
const data = await linearApi(
|
|
103
|
+
input,
|
|
104
|
+
"create",
|
|
105
|
+
"mutation($team:String!,$title:String!,$body:String!){issueCreate" +
|
|
106
|
+
"(input:{teamId:$team,title:$title,description:$body})" +
|
|
107
|
+
"{success issue{id}}}",
|
|
108
|
+
{ team: team().id, title: draft.title, body: draft.body }
|
|
109
|
+
);
|
|
110
|
+
const id = data.issueCreate?.issue?.id;
|
|
111
|
+
if (!id) refuse("linear", "create", "missing issue identity");
|
|
112
|
+
return readback("linear", list, id, true);
|
|
113
|
+
},
|
|
114
|
+
async refresh(id, draft) {
|
|
115
|
+
return mutate(
|
|
116
|
+
"refresh",
|
|
117
|
+
"mutation($id:String!,$title:String!,$body:String!){issueUpdate" +
|
|
118
|
+
"(id:$id,input:{title:$title,description:$body})" +
|
|
119
|
+
"{success issue{id}}}",
|
|
120
|
+
{ id, title: draft.title, body: draft.body },
|
|
121
|
+
id,
|
|
122
|
+
true
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
async close(id) {
|
|
126
|
+
const done = (team().states?.nodes ?? []).filter(
|
|
127
|
+
state => state.type === "completed"
|
|
128
|
+
);
|
|
129
|
+
if (done.length !== 1) {
|
|
130
|
+
refuse("linear", "close", "terminal state unavailable");
|
|
131
|
+
}
|
|
132
|
+
await mutate(
|
|
133
|
+
"close",
|
|
134
|
+
"mutation($id:String!,$state:String!){issueUpdate" +
|
|
135
|
+
"(id:$id,input:{stateId:$state}){success issue{id}}}",
|
|
136
|
+
{ id, state: done[0].id },
|
|
137
|
+
id,
|
|
138
|
+
false
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Build Sentry's issue adapter over the injected JSON transport. */
|
|
145
|
+
export function sentryAdapter(input) {
|
|
146
|
+
const org = required(input, "sentry", "SENTRY_ORG");
|
|
147
|
+
const project = required(input, "sentry", "SENTRY_PROJECT");
|
|
148
|
+
const token = required(input, "sentry", "PROVIDER_TOKEN");
|
|
149
|
+
const headers = {
|
|
150
|
+
Authorization: `Bearer ${token}`,
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
};
|
|
153
|
+
const base = `https://sentry.io/api/0/projects/${org}/${project}`;
|
|
154
|
+
const list = async (operation = "list") => {
|
|
155
|
+
const marker = JSON.stringify(CONDITION_MARKER);
|
|
156
|
+
const query = `is:unresolved ${MARKER_TAG}:${marker}`;
|
|
157
|
+
const raw = await input.request({
|
|
158
|
+
operation,
|
|
159
|
+
url: `${base}/issues/?query=${encodeURIComponent(query)}` + "&limit=100",
|
|
160
|
+
options: { method: "GET", headers },
|
|
161
|
+
});
|
|
162
|
+
if (!Array.isArray(raw)) refuse("sentry", operation, "malformed response");
|
|
163
|
+
if (raw.length > 100) refuse("sentry", operation, "result overflow");
|
|
164
|
+
return await Promise.all(
|
|
165
|
+
raw.map(issue => sentryListedGroup(input, operation, issue, headers))
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
return sentryActions(input, { base, headers, list });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolve the public ingestion target from Sentry's authenticated key list. */
|
|
172
|
+
async function sentryIngestion(input, operation, base, headers) {
|
|
173
|
+
const raw = await input.request({
|
|
174
|
+
operation,
|
|
175
|
+
url: `${base}/keys/`,
|
|
176
|
+
options: { method: "GET", headers },
|
|
177
|
+
});
|
|
178
|
+
const publicDsn = Array.isArray(raw) ? raw[0]?.dsn?.public : null;
|
|
179
|
+
let dsn;
|
|
180
|
+
try {
|
|
181
|
+
dsn = new URL(publicDsn);
|
|
182
|
+
} catch {
|
|
183
|
+
refuse("sentry", operation, "invalid public DSN");
|
|
184
|
+
}
|
|
185
|
+
const projectId = dsn.pathname.match(/^\/(\d+)$/u)?.[1];
|
|
186
|
+
if (
|
|
187
|
+
dsn.protocol !== "https:" ||
|
|
188
|
+
!dsn.username ||
|
|
189
|
+
dsn.password ||
|
|
190
|
+
!projectId ||
|
|
191
|
+
dsn.search ||
|
|
192
|
+
dsn.hash
|
|
193
|
+
) {
|
|
194
|
+
refuse("sentry", operation, "invalid public DSN");
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
url: `https://${dsn.host}/api/${projectId}/store/`,
|
|
198
|
+
key: decodeURIComponent(dsn.username),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Resolve one ingested event to its separately owned Sentry group. */
|
|
203
|
+
async function sentryGroup(input, eventId, base, headers) {
|
|
204
|
+
const raw = await input.request({
|
|
205
|
+
operation: "readback",
|
|
206
|
+
url: `${base}/events/${eventId}/`,
|
|
207
|
+
options: { method: "GET", headers },
|
|
208
|
+
});
|
|
209
|
+
const marker = Array.isArray(raw?.tags)
|
|
210
|
+
? raw.tags.filter(
|
|
211
|
+
tag => tag?.key === MARKER_TAG && tag.value === CONDITION_MARKER
|
|
212
|
+
)
|
|
213
|
+
: [];
|
|
214
|
+
if (
|
|
215
|
+
raw?.eventID !== eventId ||
|
|
216
|
+
typeof raw?.groupID !== "string" ||
|
|
217
|
+
!raw.groupID ||
|
|
218
|
+
raw.groupID === eventId ||
|
|
219
|
+
marker.length !== 1
|
|
220
|
+
) {
|
|
221
|
+
refuse("sentry", "readback", "event identity or marker authority");
|
|
222
|
+
}
|
|
223
|
+
return raw.groupID;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Materialize Sentry's write operations around the shared readback. */
|
|
227
|
+
function sentryActions(input, context) {
|
|
228
|
+
const { base, headers, list } = context;
|
|
229
|
+
const write = async (operation, id, draft) => {
|
|
230
|
+
const ingestion = await sentryIngestion(input, operation, base, headers);
|
|
231
|
+
const raw = await input.request({
|
|
232
|
+
operation,
|
|
233
|
+
url: ingestion.url,
|
|
234
|
+
options: {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: {
|
|
237
|
+
"Content-Type": "application/json",
|
|
238
|
+
"X-Sentry-Auth":
|
|
239
|
+
"Sentry sentry_version=7, " + `sentry_key=${ingestion.key}`,
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
message: draft.title,
|
|
243
|
+
fingerprint: [draft.marker],
|
|
244
|
+
level: "error",
|
|
245
|
+
tags: { [MARKER_TAG]: draft.marker },
|
|
246
|
+
extra: { body: draft.body },
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
const eventId = raw?.id;
|
|
251
|
+
if (typeof eventId !== "string" || !eventId) {
|
|
252
|
+
refuse("sentry", operation, "missing event identity");
|
|
253
|
+
}
|
|
254
|
+
const groupId = await sentryGroup(input, eventId, base, headers);
|
|
255
|
+
if (id !== null && id !== groupId) {
|
|
256
|
+
refuse("sentry", "readback", "group identity mismatch");
|
|
257
|
+
}
|
|
258
|
+
return readback("sentry", list, groupId, true);
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
async list() {
|
|
262
|
+
return list();
|
|
263
|
+
},
|
|
264
|
+
async create(draft) {
|
|
265
|
+
return write("create", null, draft);
|
|
266
|
+
},
|
|
267
|
+
async refresh(id, draft) {
|
|
268
|
+
return write("refresh", id, draft);
|
|
269
|
+
},
|
|
270
|
+
async close(id) {
|
|
271
|
+
await input.request({
|
|
272
|
+
operation: "close",
|
|
273
|
+
url: `https://sentry.io/api/0/issues/${id}/`,
|
|
274
|
+
options: {
|
|
275
|
+
method: "PUT",
|
|
276
|
+
headers,
|
|
277
|
+
body: '{"status":"resolved"}',
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
await readback("sentry", list, id, false);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
/** Shared bounded provider transport and authority checks. */
|
|
5
|
+
import { CONDITION_MARKER } from "./reconcile-nightly-e2e-tracking.mjs";
|
|
6
|
+
|
|
7
|
+
/** Throw a bounded diagnostic without response or credential data. */
|
|
8
|
+
export function refuse(destination, operation, status = "provider error") {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Nightly E2E ${destination} ${operation} refused: ${status}`.slice(0, 4096)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Read a required environment value without echoing it. */
|
|
15
|
+
export function required(input, destination, name) {
|
|
16
|
+
const value = input.env[name];
|
|
17
|
+
if (!value?.trim()) refuse(destination, "configuration", `missing ${name}`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Require an exact post-write authority snapshot. */
|
|
22
|
+
export async function readback(destination, list, id, present) {
|
|
23
|
+
const records = await list("readback");
|
|
24
|
+
const exact = records.filter(record => record.id === id);
|
|
25
|
+
if (
|
|
26
|
+
present &&
|
|
27
|
+
records.length === 1 &&
|
|
28
|
+
exact.length === 1 &&
|
|
29
|
+
exact[0].marker === CONDITION_MARKER
|
|
30
|
+
) {
|
|
31
|
+
return exact[0];
|
|
32
|
+
}
|
|
33
|
+
if (!present && exact.length === 0 && records.length === 0) return null;
|
|
34
|
+
refuse(destination, "readback", "tracker authority mismatch");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Default fetch transport used by the executable installed script. */
|
|
38
|
+
export async function fetchJson(request) {
|
|
39
|
+
let response;
|
|
40
|
+
try {
|
|
41
|
+
response = await fetch(request.url, {
|
|
42
|
+
...request.options,
|
|
43
|
+
signal: AbortSignal.timeout(30_000),
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("provider transport error");
|
|
47
|
+
}
|
|
48
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
49
|
+
if (response.status === 204) return null;
|
|
50
|
+
try {
|
|
51
|
+
return await response.json();
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error("provider malformed response");
|
|
54
|
+
}
|
|
55
|
+
}
|