@yanolja-next/noya-sdk 0.1.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 ADDED
@@ -0,0 +1,107 @@
1
+ # Noya
2
+
3
+ Production issue reports into pre-triaged Jira tickets and ready-to-review PRs.
4
+
5
+ This repository contains a local Node.js + TypeScript MVP:
6
+
7
+ - Browser SDK wrapper entry point: `src/sdk.ts`
8
+ - Backend orchestrator and dashboard: `src/server.ts`, `public/`
9
+ - Customer runner: `src/runner.ts`
10
+ - Local issue and draft PR artifacts by default
11
+ - Optional GitHub/Jira issue providers when explicitly configured with token
12
+ environment variables
13
+ - GitHub/Jira issues receive a fix-result comment after local draft PR creation
14
+ - Project integration config rejects inline credentials; use `*_env` settings
15
+ for token/secret references
16
+ - Optional telemetry event lookup when a project has provider integration settings
17
+ - Optional provider webhook HMAC verification via
18
+ `integrations.telemetry.webhook_secret_env`
19
+ - SDK session recording wiring with optional Replay/Feedback integration
20
+ registration
21
+ - Optional runner Bearer authentication via project `runner.token_env`
22
+ - Approved fix command refs for local Claude Code/agent execution, with
23
+ shell-free process spawning, isolated repo checkout, and local draft PR
24
+ artifacts
25
+ - Checkout status and patch artifacts are attached to local draft PR output when
26
+ an approved agent changes files
27
+ - Artifact serving is constrained to each project's `artifact_dir`
28
+ - Optional dashboard/admin artifact protection via `NOYA_ADMIN_TOKEN`
29
+ - Project-level artifact retention cleanup via
30
+ `POST /api/projects/:project_key/retention/cleanup`
31
+ - Idempotent manual fix approval so repeated clicks do not create duplicate jobs
32
+ - Audit entries for individual collector policy decisions and fix agent command
33
+ execution
34
+ - SDK network correlation for `x-request-id`, `x-trace-id`, and `traceparent`
35
+ - SDK/provider screenshot URLs are preserved as issue evidence
36
+ - Telemetry breadcrumbs are surfaced in compiled issue bodies
37
+ - Runner lease timeout/requeue and collector `max_entries` / `max_bytes` limits
38
+ - Server-side validation for collect/fix runner completion payloads
39
+ - No-runner flow creates an immediate issue with unavailable internal context
40
+ clearly marked
41
+ - Dashboard links for local issue and draft PR artifacts
42
+ - Dashboard readiness checks for artifact storage, runner repo, tokens, and issue
43
+ providers
44
+ - Dashboard project config editor with server-side validation
45
+ - Project edits preserve immutable `created_at` metadata
46
+ - agency-platform customer sample: `examples/agency-platform/`
47
+
48
+ The local implementation intentionally does not deploy, push, merge, or write to
49
+ production systems. The PR flow creates a local draft PR artifact for review.
50
+ External issue providers are opt-in and are covered by mock-server tests.
51
+
52
+ - [PRD](docs/PRD.md)
53
+ - [AI agent integration guide](docs/AGENT_INTEGRATION.md)
54
+ - [GCP deployment guide](docs/DEPLOY_GCP.md)
55
+
56
+ ## Run Locally
57
+
58
+ ```bash
59
+ npm install
60
+ npm run build
61
+ npm start
62
+ ```
63
+
64
+ Open `http://localhost:3456`.
65
+
66
+ To simulate the customer runner once:
67
+
68
+ ```bash
69
+ npm run runner -- examples/agency-platform/noya.runner.json
70
+ ```
71
+
72
+ If the project is configured with `runner.token_env`, export the same token
73
+ before running the runner:
74
+
75
+ ```bash
76
+ export NOYA_AGENCY_PLATFORM_RUNNER_TOKEN=local-runner-token
77
+ ```
78
+
79
+ To protect dashboard, local artifact, project config, retention cleanup, and fix
80
+ approval endpoints in a shared local environment:
81
+
82
+ ```bash
83
+ export NOYA_ADMIN_TOKEN=local-admin-token
84
+ ```
85
+
86
+ The dashboard sends this token from `localStorage.noya_admin_token` when set.
87
+
88
+ ## Verify
89
+
90
+ ```bash
91
+ npm test
92
+ npm run test:e2e
93
+ ```
94
+
95
+ `npm run test:e2e` starts the orchestrator, submits an agency-platform sample
96
+ report, runs collect mode, approves a local fix from the dashboard in headless
97
+ Chrome, runs fix mode, and verifies that a local draft PR artifact was created.
98
+ It also verifies telemetry webhook intake, collector policy denial for raw command
99
+ collectors, telemetry event lookup, GitHub/Jira providers against local mock
100
+ servers, approved fix command execution, runner heartbeat/fail lifecycle, and
101
+ runner Bearer authentication. `npm test` also verifies provider webhook signature
102
+ checks, stale runner lease requeue, collector output limits, duplicate issue
103
+ linking, idempotent fix approval, collector/agent audit entries,
104
+ browser-visible artifact links, readiness checks, SDK request correlation,
105
+ project config validation, context redaction, and recording tag/context wiring.
106
+ It also checks artifact retention cleanup and artifact path boundary enforcement.
107
+ Optional admin-token protection is covered by API tests.
@@ -0,0 +1,25 @@
1
+ import type { Job, JsonObject, Project, Issue } from "./types.js";
2
+ import type { JsonStore } from "./store.js";
3
+ export declare function createProviderIssue({ store, project, issue, }: {
4
+ store: JsonStore;
5
+ project: Project;
6
+ issue: Issue;
7
+ }): Promise<JsonObject>;
8
+ export declare function updateProviderIssueAfterFix({ store, project, issue, draftPr, result, }: {
9
+ store: JsonStore;
10
+ project: Project;
11
+ issue: Issue;
12
+ draftPr: JsonObject;
13
+ result: JsonObject;
14
+ }): Promise<JsonObject | null>;
15
+ export declare function createLocalDraftPr({ store, project, job, result, }: {
16
+ store: JsonStore;
17
+ project: Project;
18
+ job: Job;
19
+ result: {
20
+ branch?: string;
21
+ changed_files?: string[];
22
+ tests?: string[];
23
+ transcript?: string;
24
+ };
25
+ }): JsonObject;
@@ -0,0 +1,244 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export async function createProviderIssue({ store, project, issue, }) {
4
+ if (project.issue_provider === "github") {
5
+ return createGitHubIssue({ store, project, issue });
6
+ }
7
+ if (project.issue_provider === "jira") {
8
+ return createJiraIssue({ store, project, issue });
9
+ }
10
+ return createLocalIssue({ store, project, issue });
11
+ }
12
+ export async function updateProviderIssueAfterFix({ store, project, issue, draftPr, result, }) {
13
+ if (project.issue_provider === "github") {
14
+ return createGitHubFixComment({ store, project, issue, draftPr, result });
15
+ }
16
+ if (project.issue_provider === "jira") {
17
+ return createJiraFixComment({ store, project, issue, draftPr, result });
18
+ }
19
+ return null;
20
+ }
21
+ function createLocalIssue({ store, project, issue, }) {
22
+ const dir = path.join(project.artifact_dir ?? ".noya/artifacts", "issues");
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ const filePath = path.join(dir, `${issue.id}.md`);
25
+ fs.writeFileSync(filePath, `# ${issue.title}\n\n${issue.body}\n`);
26
+ store.audit("issue.created.local", {
27
+ project_key: project.project_key,
28
+ issue_id: issue.id,
29
+ path: filePath,
30
+ });
31
+ return {
32
+ provider: project.issue_provider ?? "local",
33
+ external_id: issue.id,
34
+ url: `file://${filePath}`,
35
+ web_url: `/artifacts/issues/${issue.id}`,
36
+ path: filePath,
37
+ };
38
+ }
39
+ async function createGitHubIssue({ store, project, issue, }) {
40
+ const github = (project.integrations?.github ?? {});
41
+ const apiBase = String(github.api_base ?? "https://api.github.com");
42
+ const owner = requiredString(github.owner, "github.owner");
43
+ const repo = requiredString(github.repo, "github.repo");
44
+ const token = tokenFromEnv(requiredString(github.token_env, "github.token_env"));
45
+ const response = await fetch(`${apiBase}/repos/${owner}/${repo}/issues`, {
46
+ method: "POST",
47
+ headers: {
48
+ authorization: `Bearer ${token}`,
49
+ accept: "application/vnd.github+json",
50
+ "content-type": "application/json",
51
+ "user-agent": "noya-local",
52
+ },
53
+ body: JSON.stringify({ title: issue.title, body: issue.body }),
54
+ });
55
+ if (!response.ok)
56
+ throw new Error(`GitHub issue creation failed: ${response.status}`);
57
+ const payload = (await response.json());
58
+ store.audit("issue.created.github", {
59
+ project_key: project.project_key,
60
+ issue_id: issue.id,
61
+ external_id: String(payload.number ?? payload.id ?? ""),
62
+ });
63
+ return {
64
+ provider: "github",
65
+ external_id: String(payload.number ?? payload.id ?? ""),
66
+ url: String(payload.html_url ?? payload.url ?? ""),
67
+ };
68
+ }
69
+ async function createJiraIssue({ store, project, issue, }) {
70
+ const jira = (project.integrations?.jira ?? {});
71
+ const apiBase = requiredString(jira.api_base, "jira.api_base").replace(/\/$/, "");
72
+ const projectKey = requiredString(jira.project_key, "jira.project_key");
73
+ const issueType = String(jira.issue_type ?? "Bug");
74
+ const email = requiredString(jira.email, "jira.email");
75
+ const token = tokenFromEnv(requiredString(jira.token_env, "jira.token_env"));
76
+ const response = await fetch(`${apiBase}/rest/api/3/issue`, {
77
+ method: "POST",
78
+ headers: {
79
+ authorization: `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`,
80
+ accept: "application/json",
81
+ "content-type": "application/json",
82
+ },
83
+ body: JSON.stringify({
84
+ fields: {
85
+ project: { key: projectKey },
86
+ issuetype: { name: issueType },
87
+ summary: issue.title,
88
+ description: jiraDoc(issue.body),
89
+ },
90
+ }),
91
+ });
92
+ if (!response.ok)
93
+ throw new Error(`Jira issue creation failed: ${response.status}`);
94
+ const payload = (await response.json());
95
+ store.audit("issue.created.jira", {
96
+ project_key: project.project_key,
97
+ issue_id: issue.id,
98
+ external_id: String(payload.key ?? payload.id ?? ""),
99
+ });
100
+ return {
101
+ provider: "jira",
102
+ external_id: String(payload.key ?? payload.id ?? ""),
103
+ url: payload.self ? String(payload.self) : "",
104
+ };
105
+ }
106
+ async function createGitHubFixComment({ store, project, issue, draftPr, result, }) {
107
+ const github = (project.integrations?.github ?? {});
108
+ const apiBase = String(github.api_base ?? "https://api.github.com");
109
+ const owner = requiredString(github.owner, "github.owner");
110
+ const repo = requiredString(github.repo, "github.repo");
111
+ const token = tokenFromEnv(requiredString(github.token_env, "github.token_env"));
112
+ const issueNumber = requiredString(issue.integration?.external_id, "github issue external_id");
113
+ const response = await fetch(`${apiBase}/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
114
+ method: "POST",
115
+ headers: {
116
+ authorization: `Bearer ${token}`,
117
+ accept: "application/vnd.github+json",
118
+ "content-type": "application/json",
119
+ "user-agent": "noya-local",
120
+ },
121
+ body: JSON.stringify({ body: fixCommentMarkdown({ draftPr, result }) }),
122
+ });
123
+ if (!response.ok)
124
+ throw new Error(`GitHub issue comment failed: ${response.status}`);
125
+ const payload = (await response.json());
126
+ store.audit("fix.provider_update.github", {
127
+ project_key: project.project_key,
128
+ issue_id: issue.id,
129
+ external_id: issueNumber,
130
+ });
131
+ return {
132
+ provider: "github",
133
+ external_id: String(payload.id ?? ""),
134
+ url: String(payload.html_url ?? payload.url ?? ""),
135
+ };
136
+ }
137
+ async function createJiraFixComment({ store, project, issue, draftPr, result, }) {
138
+ const jira = (project.integrations?.jira ?? {});
139
+ const apiBase = requiredString(jira.api_base, "jira.api_base").replace(/\/$/, "");
140
+ const email = requiredString(jira.email, "jira.email");
141
+ const token = tokenFromEnv(requiredString(jira.token_env, "jira.token_env"));
142
+ const issueKey = requiredString(issue.integration?.external_id, "jira issue external_id");
143
+ const response = await fetch(`${apiBase}/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`, {
144
+ method: "POST",
145
+ headers: {
146
+ authorization: `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`,
147
+ accept: "application/json",
148
+ "content-type": "application/json",
149
+ },
150
+ body: JSON.stringify({ body: jiraDoc(fixCommentMarkdown({ draftPr, result })) }),
151
+ });
152
+ if (!response.ok)
153
+ throw new Error(`Jira issue comment failed: ${response.status}`);
154
+ const payload = (await response.json());
155
+ store.audit("fix.provider_update.jira", {
156
+ project_key: project.project_key,
157
+ issue_id: issue.id,
158
+ external_id: issueKey,
159
+ });
160
+ return {
161
+ provider: "jira",
162
+ external_id: String(payload.id ?? ""),
163
+ url: String(payload.self ?? ""),
164
+ };
165
+ }
166
+ function fixCommentMarkdown({ draftPr, result }) {
167
+ return [
168
+ "## Noya Fix Result",
169
+ "",
170
+ `Local draft PR: ${String(draftPr.web_url ?? draftPr.url ?? "unavailable")}`,
171
+ `Branch: ${String(result.branch ?? "unavailable")}`,
172
+ "",
173
+ "### Changed Files",
174
+ bulletList(result.changed_files),
175
+ "",
176
+ "### Verification",
177
+ bulletList(result.tests),
178
+ "",
179
+ "Push, merge, deploy, and production writes were not performed by Noya.",
180
+ ].join("\n");
181
+ }
182
+ function bulletList(value) {
183
+ if (!Array.isArray(value) || value.length === 0)
184
+ return "- Unavailable.";
185
+ return value.map((entry) => `- ${String(entry)}`).join("\n");
186
+ }
187
+ function requiredString(value, name) {
188
+ if (typeof value !== "string" || value.length === 0) {
189
+ throw new Error(`Missing ${name} integration setting`);
190
+ }
191
+ return value;
192
+ }
193
+ function tokenFromEnv(name) {
194
+ const value = process.env[name];
195
+ if (!value)
196
+ throw new Error(`Missing integration token env var: ${name}`);
197
+ return value;
198
+ }
199
+ function jiraDoc(markdown) {
200
+ return {
201
+ type: "doc",
202
+ version: 1,
203
+ content: [
204
+ {
205
+ type: "paragraph",
206
+ content: [{ type: "text", text: markdown.slice(0, 32000) }],
207
+ },
208
+ ],
209
+ };
210
+ }
211
+ export function createLocalDraftPr({ store, project, job, result, }) {
212
+ const dir = path.join(project.artifact_dir ?? ".noya/artifacts", "prs");
213
+ fs.mkdirSync(dir, { recursive: true });
214
+ const filePath = path.join(dir, `${job.id}.md`);
215
+ const body = [
216
+ `# Local Draft PR: ${job.issue_id}`,
217
+ "",
218
+ `Project: ${project.name}`,
219
+ `Job: ${job.id}`,
220
+ `Branch: ${result.branch ?? "noya/local-draft"}`,
221
+ "",
222
+ "## Changed Files",
223
+ ...(result.changed_files ?? ["No file edits were made by the dry-run agent."]).map((file) => `- ${file}`),
224
+ "",
225
+ "## Verification",
226
+ ...(result.tests ?? ["No tests configured."]).map((test) => `- ${test}`),
227
+ "",
228
+ "## Transcript",
229
+ result.transcript ?? "No transcript.",
230
+ ].join("\n");
231
+ fs.writeFileSync(filePath, `${body}\n`);
232
+ store.audit("fix.local_draft_pr.created", {
233
+ project_key: project.project_key,
234
+ job_id: job.id,
235
+ path: filePath,
236
+ });
237
+ return {
238
+ provider: "local_draft_pr",
239
+ external_id: job.id,
240
+ url: `file://${filePath}`,
241
+ web_url: `/artifacts/prs/${job.id}`,
242
+ path: filePath,
243
+ };
244
+ }
@@ -0,0 +1,17 @@
1
+ import type { JsonValue, Project, Report } from "./types.js";
2
+ type CollectorResult = {
3
+ context?: {
4
+ logs?: JsonValue;
5
+ domain_snapshot?: JsonValue;
6
+ };
7
+ policy?: JsonValue;
8
+ };
9
+ export declare function compileIssue({ report, project, collectorResult, }: {
10
+ report: Report;
11
+ project: Project;
12
+ collectorResult?: CollectorResult | null;
13
+ }): {
14
+ title: string;
15
+ body: string;
16
+ };
17
+ export {};
@@ -0,0 +1,81 @@
1
+ function bulletList(values) {
2
+ if (!values || values.length === 0)
3
+ return "- Unavailable.";
4
+ return values.map((value) => `- ${value}`).join("\n");
5
+ }
6
+ function objectBlock(value) {
7
+ if (!value || typeof value !== "object" || Object.keys(value).length === 0)
8
+ return "Unavailable.";
9
+ return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
10
+ }
11
+ function optionalStrings(values) {
12
+ return values.filter((value) => typeof value === "string" && value.length > 0);
13
+ }
14
+ function feedbackMessage(feedback) {
15
+ if (!feedback || typeof feedback !== "object" || Array.isArray(feedback))
16
+ return undefined;
17
+ const value = feedback.message;
18
+ return typeof value === "string" ? value : undefined;
19
+ }
20
+ export function compileIssue({ report, project, collectorResult, }) {
21
+ const context = collectorResult?.context ?? {};
22
+ const telemetry = report.telemetry ?? report.sentry ?? {};
23
+ const hints = report.hints ?? {};
24
+ const summary = report.message ||
25
+ feedbackMessage(report.feedback) ||
26
+ `${project.name}: production issue report ${report.id}`;
27
+ const title = `[${project.name}] ${summary}`.slice(0, 140);
28
+ const body = [
29
+ `# Summary\n${summary}`,
30
+ `# Impact\n${report.impact || "User-reported production issue. Impact needs owner confirmation."}`,
31
+ `# Environment\n${objectBlock({
32
+ project: project.name,
33
+ environment: report.environment,
34
+ release: report.release,
35
+ route: report.route,
36
+ browser: report.browser,
37
+ })}`,
38
+ `# Evidence\n${bulletList(optionalStrings([
39
+ telemetry.event_id ? `Telemetry event: ${telemetry.event_id}` : null,
40
+ telemetry.replay_id ? `Session replay: ${telemetry.replay_id}` : null,
41
+ telemetry.feedback_id ? `User feedback: ${telemetry.feedback_id}` : null,
42
+ report.screenshot_url ? `Screenshot: ${report.screenshot_url}` : null,
43
+ report.request_id ? `Request ID: ${report.request_id}` : null,
44
+ ]))}`,
45
+ `# Reproduction Steps\n${bulletList(report.reproduction_steps)}`,
46
+ `# Expected Behavior\n${report.expected_behavior || "The user can complete the intended workflow without errors."}`,
47
+ `# Actual Behavior\n${report.actual_behavior || summary}`,
48
+ `# Confirmed Facts\n${bulletList(optionalStrings([
49
+ `Report ID ${report.id} was received by Noya.`,
50
+ report.route ? `The report was captured on route ${report.route}.` : null,
51
+ Object.keys(hints).length > 0 ? `Correlation hints were extracted: ${Object.keys(hints).join(", ")}.` : null,
52
+ collectorResult ? "Customer runner returned a redacted context bundle." : null,
53
+ ]))}`,
54
+ `# Inferences / Suspected Cause\n${bulletList([
55
+ report.suspected_area
56
+ ? `Suspected area from metadata: ${report.suspected_area}.`
57
+ : "Needs engineering investigation; Noya is not asserting root cause.",
58
+ ])}`,
59
+ `# Relevant Logs\n${objectBlock(context.logs)}`,
60
+ `# Relevant Domain Snapshot\n${objectBlock(context.domain_snapshot)}`,
61
+ `# Telemetry Breadcrumbs\n${objectBlock(report.context?.sentry_breadcrumbs)}`,
62
+ `# Collector Policy Decisions\n${objectBlock(collectorResult?.policy)}`,
63
+ `# Telemetry Links\n${bulletList(optionalStrings([
64
+ telemetry.event_url ? telemetry.event_url : null,
65
+ telemetry.replay_url ? telemetry.replay_url : null,
66
+ ]))}`,
67
+ `# Agent Investigation Hints\n${objectBlock({
68
+ hints,
69
+ frontend_context: report.context ?? {},
70
+ suspected_files: report.suspected_files ?? [],
71
+ repo_path: project.runner?.repo_path,
72
+ })}`,
73
+ `# Definition of Done\n${bulletList([
74
+ "Root cause is confirmed or explicitly ruled out.",
75
+ "Regression test or focused verification is added when practical.",
76
+ "Fix opens a reviewable PR or local draft PR artifact.",
77
+ "No merge, deploy, or production write is performed by Noya.",
78
+ ])}`,
79
+ ].join("\n\n");
80
+ return { title, body };
81
+ }
@@ -0,0 +1,59 @@
1
+ import type { Capabilities, Issue, Job, JsonObject, Project, Report } from "./types.js";
2
+ import type { JsonStore } from "./store.js";
3
+ type ProjectInput = Partial<Project> & Pick<Project, "project_key">;
4
+ type ReportEnvelope = JsonObject & {
5
+ report_id?: string;
6
+ project_key: string;
7
+ message?: string;
8
+ impact?: string;
9
+ environment?: string;
10
+ release?: string;
11
+ route?: string;
12
+ browser?: string;
13
+ request_id?: string;
14
+ screenshot_url?: string;
15
+ telemetry?: Report["telemetry"];
16
+ sentry?: Report["sentry"];
17
+ feedback?: Report["feedback"];
18
+ context?: Report["context"];
19
+ reproduction_steps?: string[];
20
+ expected_behavior?: string;
21
+ actual_behavior?: string;
22
+ suspected_area?: string;
23
+ suspected_files?: string[];
24
+ };
25
+ export declare function receiveSentryWebhook(store: JsonStore, payload: JsonObject): Promise<{
26
+ report: Report;
27
+ job: Job;
28
+ issue: null;
29
+ } | {
30
+ report: Report;
31
+ job: null;
32
+ issue: Issue;
33
+ }>;
34
+ export declare function upsertProject(store: JsonStore, input: ProjectInput): Project;
35
+ export declare function receiveReport(store: JsonStore, envelope: ReportEnvelope): Promise<{
36
+ report: Report;
37
+ job: Job;
38
+ issue: null;
39
+ } | {
40
+ report: Report;
41
+ job: null;
42
+ issue: Issue;
43
+ }>;
44
+ export declare function nextRunnerJob(store: JsonStore, { projectKey, runnerId, capabilities, }: {
45
+ projectKey: string | null;
46
+ runnerId: string;
47
+ capabilities: Capabilities;
48
+ }): Job | null;
49
+ export declare function heartbeatJob(store: JsonStore, jobId: string): Job;
50
+ export declare function completeJob(store: JsonStore, jobId: string, result: JsonObject): Promise<{
51
+ job: Job;
52
+ issue: Issue;
53
+ } | {
54
+ job: Job;
55
+ issue?: undefined;
56
+ }>;
57
+ export declare function failJob(store: JsonStore, jobId: string, error: JsonObject): Job;
58
+ export declare function approveFix(store: JsonStore, issueId: string): Job;
59
+ export {};