@prsense/daemon 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.
Files changed (113) hide show
  1. package/LICENSE +201 -0
  2. package/dist/bootstrap.d.ts +8 -0
  3. package/dist/bootstrap.d.ts.map +1 -0
  4. package/dist/bootstrap.js +22 -0
  5. package/dist/bootstrap.js.map +1 -0
  6. package/dist/http/health.d.ts +5 -0
  7. package/dist/http/health.d.ts.map +1 -0
  8. package/dist/http/health.js +23 -0
  9. package/dist/http/health.js.map +1 -0
  10. package/dist/http/jobs.d.ts +6 -0
  11. package/dist/http/jobs.d.ts.map +1 -0
  12. package/dist/http/jobs.js +65 -0
  13. package/dist/http/jobs.js.map +1 -0
  14. package/dist/http/webhooks/github.d.ts +9 -0
  15. package/dist/http/webhooks/github.d.ts.map +1 -0
  16. package/dist/http/webhooks/github.js +65 -0
  17. package/dist/http/webhooks/github.js.map +1 -0
  18. package/dist/http/webhooks/gitlab.d.ts +9 -0
  19. package/dist/http/webhooks/gitlab.d.ts.map +1 -0
  20. package/dist/http/webhooks/gitlab.js +56 -0
  21. package/dist/http/webhooks/gitlab.js.map +1 -0
  22. package/dist/idempotency/deliveryRegistry.d.ts +5 -0
  23. package/dist/idempotency/deliveryRegistry.d.ts.map +1 -0
  24. package/dist/idempotency/deliveryRegistry.js +24 -0
  25. package/dist/idempotency/deliveryRegistry.js.map +1 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +50 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/jobs/index/runIndexJob.d.ts +5 -0
  31. package/dist/jobs/index/runIndexJob.d.ts.map +1 -0
  32. package/dist/jobs/index/runIndexJob.js +20 -0
  33. package/dist/jobs/index/runIndexJob.js.map +1 -0
  34. package/dist/jobs/index/types.d.ts +6 -0
  35. package/dist/jobs/index/types.d.ts.map +1 -0
  36. package/dist/jobs/index/types.js +2 -0
  37. package/dist/jobs/index/types.js.map +1 -0
  38. package/dist/jobs/review/runReviewJob.d.ts +5 -0
  39. package/dist/jobs/review/runReviewJob.d.ts.map +1 -0
  40. package/dist/jobs/review/runReviewJob.js +87 -0
  41. package/dist/jobs/review/runReviewJob.js.map +1 -0
  42. package/dist/jobs/review/types.d.ts +5 -0
  43. package/dist/jobs/review/types.d.ts.map +1 -0
  44. package/dist/jobs/review/types.js +2 -0
  45. package/dist/jobs/review/types.js.map +1 -0
  46. package/dist/jobs/runJob.d.ts +4 -0
  47. package/dist/jobs/runJob.d.ts.map +1 -0
  48. package/dist/jobs/runJob.js +34 -0
  49. package/dist/jobs/runJob.js.map +1 -0
  50. package/dist/jobs/store.d.ts +12 -0
  51. package/dist/jobs/store.d.ts.map +1 -0
  52. package/dist/jobs/store.js +56 -0
  53. package/dist/jobs/store.js.map +1 -0
  54. package/dist/jobs/types.d.ts +14 -0
  55. package/dist/jobs/types.d.ts.map +1 -0
  56. package/dist/jobs/types.js +2 -0
  57. package/dist/jobs/types.js.map +1 -0
  58. package/dist/lifecycle/index.d.ts +5 -0
  59. package/dist/lifecycle/index.d.ts.map +1 -0
  60. package/dist/lifecycle/index.js +5 -0
  61. package/dist/lifecycle/index.js.map +1 -0
  62. package/dist/lifecycle/start.d.ts +4 -0
  63. package/dist/lifecycle/start.d.ts.map +1 -0
  64. package/dist/lifecycle/start.js +24 -0
  65. package/dist/lifecycle/start.js.map +1 -0
  66. package/dist/lifecycle/state.d.ts +3 -0
  67. package/dist/lifecycle/state.d.ts.map +1 -0
  68. package/dist/lifecycle/state.js +11 -0
  69. package/dist/lifecycle/state.js.map +1 -0
  70. package/dist/lifecycle/status.d.ts +2 -0
  71. package/dist/lifecycle/status.d.ts.map +1 -0
  72. package/dist/lifecycle/status.js +15 -0
  73. package/dist/lifecycle/status.js.map +1 -0
  74. package/dist/lifecycle/stop.d.ts +2 -0
  75. package/dist/lifecycle/stop.d.ts.map +1 -0
  76. package/dist/lifecycle/stop.js +11 -0
  77. package/dist/lifecycle/stop.js.map +1 -0
  78. package/dist/logger.d.ts +3 -0
  79. package/dist/logger.d.ts.map +1 -0
  80. package/dist/logger.js +8 -0
  81. package/dist/logger.js.map +1 -0
  82. package/dist/security/verifyGitLabWebhook.d.ts +5 -0
  83. package/dist/security/verifyGitLabWebhook.d.ts.map +1 -0
  84. package/dist/security/verifyGitLabWebhook.js +15 -0
  85. package/dist/security/verifyGitLabWebhook.js.map +1 -0
  86. package/dist/shutdown.d.ts +5 -0
  87. package/dist/shutdown.d.ts.map +1 -0
  88. package/dist/shutdown.js +16 -0
  89. package/dist/shutdown.js.map +1 -0
  90. package/package.json +47 -0
  91. package/src/bootstrap.ts +32 -0
  92. package/src/http/health.ts +32 -0
  93. package/src/http/jobs.ts +108 -0
  94. package/src/http/webhooks/github.ts +106 -0
  95. package/src/http/webhooks/gitlab.ts +91 -0
  96. package/src/idempotency/deliveryRegistry.ts +31 -0
  97. package/src/index.ts +75 -0
  98. package/src/jobs/index/runIndexJob.ts +29 -0
  99. package/src/jobs/index/types.ts +6 -0
  100. package/src/jobs/review/runReviewJob.ts +126 -0
  101. package/src/jobs/review/types.ts +5 -0
  102. package/src/jobs/runJob.ts +47 -0
  103. package/src/jobs/store.ts +70 -0
  104. package/src/jobs/types.ts +19 -0
  105. package/src/lifecycle/index.ts +4 -0
  106. package/src/lifecycle/start.ts +27 -0
  107. package/src/lifecycle/state.ts +14 -0
  108. package/src/lifecycle/status.ts +14 -0
  109. package/src/lifecycle/stop.ts +13 -0
  110. package/src/logger.ts +8 -0
  111. package/src/shutdown.ts +19 -0
  112. package/tsconfig.json +36 -0
  113. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,32 @@
1
+ // apps/daemon/src/bootstrap.ts
2
+ import type { CredentialContext, ResolvedConfig } from "@prsense/config";
3
+ import { resolveEnvironment } from "@prsense/config";
4
+
5
+ type DaemonContext = {
6
+ config: ResolvedConfig;
7
+ credentials: CredentialContext;
8
+ };
9
+ export function bootstrapDaemon(): DaemonContext {
10
+ const cwd = process.cwd();
11
+
12
+ const env = resolveEnvironment("daemon", {
13
+ root: cwd,
14
+ provider: "filesystem",
15
+ });
16
+
17
+ // 1️⃣ Load global-only user config
18
+ const errors = env.issues.filter((i) => i.level === "error");
19
+
20
+ if (errors.length > 0) {
21
+ console.error("❌ PRSense daemon configuration invalid:\n");
22
+ for (const err of errors) {
23
+ console.error(`- ${err.message}`);
24
+ }
25
+ process.exit(1);
26
+ }
27
+
28
+ return {
29
+ config: env.config,
30
+ credentials: env.credentials,
31
+ };
32
+ }
@@ -0,0 +1,32 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { Logger } from "@prsense/logging";
3
+ import type { ResolvedConfig } from "@prsense/config";
4
+ import pg from "pg";
5
+
6
+ export function registerHealthRoutes(
7
+ app: FastifyInstance,
8
+ logger: Logger,
9
+ config: ResolvedConfig,
10
+ ) {
11
+ app.get("/health", async () => {
12
+ logger.debug("health.check");
13
+ return { status: "ok" };
14
+ });
15
+
16
+ app.get("/ready", async (_, reply) => {
17
+ try {
18
+ const client = new pg.Client({
19
+ connectionString: config.database.url,
20
+ });
21
+
22
+ await client.connect();
23
+ await client.end();
24
+
25
+ return { ready: true };
26
+ } catch (err) {
27
+ logger.error("readiness.failed", { err });
28
+ reply.status(503);
29
+ return { ready: false };
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from "crypto";
2
+ import type { FastifyInstance } from "fastify";
3
+
4
+ import type { JobStore } from "../jobs/store.js";
5
+ import type { ResolvedConfig, CredentialContext } from "@prsense/config";
6
+ import type { Logger } from "@prsense/logging";
7
+
8
+ import { runJob } from "../jobs/runJob.js";
9
+ import { runIndexJob } from "../jobs/index/runIndexJob.js";
10
+ import { runReviewJob } from "../jobs/review/runReviewJob.js";
11
+
12
+ export function registerJobRoutes(
13
+ app: FastifyInstance,
14
+ store: JobStore,
15
+ config: ResolvedConfig,
16
+ credentials: CredentialContext,
17
+ logger: Logger,
18
+ ) {
19
+ /* ----------------------------- */
20
+ /* INDEX JOB */
21
+ /* ----------------------------- */
22
+
23
+ app.post("/jobs/index", async (req, reply) => {
24
+ const body = req.body as {
25
+ target: string;
26
+ force?: boolean;
27
+ dryRun?: boolean;
28
+ };
29
+
30
+ if (!body?.target) {
31
+ reply.status(400);
32
+ return { error: "target is required" };
33
+ }
34
+
35
+ const jobId = randomUUID();
36
+
37
+ store.create({
38
+ id: jobId,
39
+ type: "index",
40
+ state: "queued",
41
+ input: body,
42
+ createdAt: Date.now(),
43
+ });
44
+
45
+ runJob(store, logger, jobId, async () =>
46
+ runIndexJob(body, config, credentials, logger),
47
+ );
48
+
49
+ return { jobId };
50
+ });
51
+
52
+ /* ----------------------------- */
53
+ /* REVIEW JOB */
54
+ /* ----------------------------- */
55
+
56
+ app.post("/jobs/review", async (req, reply) => {
57
+ const body = req.body as {
58
+ target: string;
59
+ baseBranch?: string;
60
+ };
61
+
62
+ if (!body?.target) {
63
+ reply.status(400);
64
+ return { error: "target is required" };
65
+ }
66
+
67
+ const jobId = randomUUID();
68
+
69
+ store.create({
70
+ id: jobId,
71
+ type: "review",
72
+ state: "queued",
73
+ input: body,
74
+ createdAt: Date.now(),
75
+ });
76
+
77
+ runJob(store, logger, jobId, async () =>
78
+ runReviewJob(body, config, credentials, logger),
79
+ );
80
+
81
+ return { jobId };
82
+ });
83
+
84
+ /* ----------------------------- */
85
+ /* JOB STATUS */
86
+ /* ----------------------------- */
87
+
88
+ app.get("/jobs/:id", async (req, reply) => {
89
+ const id = (req.params as any).id;
90
+
91
+ const job = store.get(id);
92
+
93
+ if (!job) {
94
+ reply.status(404);
95
+ return { error: "Job not found" };
96
+ }
97
+
98
+ return job;
99
+ });
100
+
101
+ /* ----------------------------- */
102
+ /* LIST JOBS */
103
+ /* ----------------------------- */
104
+
105
+ app.get("/jobs", async () => {
106
+ return store.list();
107
+ });
108
+ }
@@ -0,0 +1,106 @@
1
+ //apps/daemon/src/http/webhooks/github.ts
2
+ import crypto from "node:crypto";
3
+ import type { FastifyInstance } from "fastify";
4
+ import type { JobStore } from "../../jobs/store.js";
5
+ import { randomUUID } from "node:crypto";
6
+ import { runJob } from "../../jobs/runJob.js";
7
+ import { runReviewJob } from "../../jobs/review/runReviewJob.js";
8
+ import type { ResolvedConfig, CredentialContext } from "@prsense/config";
9
+ import type { Logger } from "@prsense/logging";
10
+ import { createDeliveryRegistry } from "../../idempotency/deliveryRegistry.js";
11
+
12
+ type DeliveryRegistry = ReturnType<typeof createDeliveryRegistry>;
13
+
14
+ export function registerGitHubWebhook(
15
+ app: FastifyInstance,
16
+ store: JobStore,
17
+ config: ResolvedConfig,
18
+ credentials: CredentialContext,
19
+ logger: Logger,
20
+ deliveryRegistry: DeliveryRegistry,
21
+ ) {
22
+ app.post(
23
+ "/webhooks/github",
24
+ { config: { rawBody: true } },
25
+ async (req, reply) => {
26
+ const secret = credentials.github?.webhookSecret;
27
+ if (!secret) {
28
+ reply.status(500).send({ error: "Webhook not configured" });
29
+ return;
30
+ }
31
+
32
+ const signature = req.headers["x-hub-signature-256"] as string;
33
+ const rawBody = (req as any).rawBody as Buffer;
34
+
35
+ if (!signature || !rawBody) {
36
+ reply.status(400).send({ error: "Invalid request" });
37
+ return;
38
+ }
39
+
40
+ const expected =
41
+ "sha256=" +
42
+ crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
43
+
44
+ if (
45
+ !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
46
+ ) {
47
+ logger.warn("github.webhook.invalid_signature");
48
+ reply.status(401).send({ error: "Invalid signature" });
49
+ return;
50
+ }
51
+
52
+ const deliveryId = req.headers["x-github-delivery"] as string;
53
+
54
+ if (!deliveryId) {
55
+ reply.status(400).send({ error: "Missing delivery id" });
56
+ return;
57
+ }
58
+
59
+ if (deliveryRegistry.has(deliveryId)) {
60
+ logger.info("github.webhook.duplicate", { deliveryId });
61
+ reply.status(200).send({ duplicate: true });
62
+ return;
63
+ }
64
+
65
+ deliveryRegistry.register(deliveryId);
66
+
67
+ const event = req.headers["x-github-event"];
68
+
69
+ if (event !== "pull_request") {
70
+ reply.status(200).send({ ignored: true });
71
+ return;
72
+ }
73
+
74
+ const payload = req.body as any;
75
+
76
+ if (payload.action !== "opened" && payload.action !== "synchronize") {
77
+ reply.status(200).send({ ignored: true });
78
+ return;
79
+ }
80
+
81
+ const prUrl = payload.pull_request?.html_url;
82
+ if (!prUrl) {
83
+ reply.status(400).send({ error: "Malformed PR event" });
84
+ return;
85
+ }
86
+
87
+ const jobId = randomUUID();
88
+
89
+ store.create({
90
+ id: jobId,
91
+ type: "review",
92
+ state: "queued",
93
+ input: { target: prUrl },
94
+ createdAt: Date.now(),
95
+ });
96
+
97
+ runJob(store, logger, jobId, async () =>
98
+ runReviewJob({ target: prUrl }, config, credentials, logger),
99
+ );
100
+
101
+ logger.info("github.webhook.job_created", { jobId, prUrl });
102
+
103
+ reply.send({ jobId });
104
+ },
105
+ );
106
+ }
@@ -0,0 +1,91 @@
1
+ //apps/daemon/src/http/webhooks/gitlab.ts
2
+ import type { FastifyInstance } from "fastify";
3
+ import type { JobStore } from "../../jobs/store.js";
4
+ import { randomUUID } from "node:crypto";
5
+ import { runJob } from "../../jobs/runJob.js";
6
+ import { runReviewJob } from "../../jobs/review/runReviewJob.js";
7
+ import type { ResolvedConfig, CredentialContext } from "@prsense/config";
8
+ import type { Logger } from "@prsense/logging";
9
+ import { createDeliveryRegistry } from "../../idempotency/deliveryRegistry.js";
10
+
11
+ type DeliveryRegistry = ReturnType<typeof createDeliveryRegistry>;
12
+
13
+ export function registerGitLabWebhook(
14
+ app: FastifyInstance,
15
+ store: JobStore,
16
+ config: ResolvedConfig,
17
+ credentials: CredentialContext,
18
+ logger: Logger,
19
+ deliveryRegistry: DeliveryRegistry,
20
+ ) {
21
+ app.post("/webhooks/gitlab", async (req, reply) => {
22
+ const secret = credentials.gitlab?.webhookSecret;
23
+ if (!secret) {
24
+ reply.status(500).send({ error: "Webhook not configured" });
25
+ return;
26
+ }
27
+
28
+ const token = req.headers["x-gitlab-token"];
29
+
30
+ if (token !== secret) {
31
+ logger.warn("gitlab.webhook.invalid_token");
32
+ reply.status(401).send({ error: "Invalid token" });
33
+ return;
34
+ }
35
+
36
+ const deliveryId =
37
+ (req.headers["x-gitlab-event-uuid"] as string) ?? undefined;
38
+
39
+ if (!deliveryId) {
40
+ reply.status(400).send({ error: "Missing delivery UUID" });
41
+ return;
42
+ }
43
+
44
+ if (deliveryRegistry.has(deliveryId)) {
45
+ logger.info("gitlab.webhook.duplicate", { deliveryId });
46
+ reply.status(200).send({ duplicate: true });
47
+ return;
48
+ }
49
+
50
+ deliveryRegistry.register(deliveryId);
51
+
52
+ const payload = req.body as any;
53
+
54
+ if (payload.object_kind !== "merge_request") {
55
+ reply.status(200).send({ ignored: true });
56
+ return;
57
+ }
58
+
59
+ if (
60
+ payload.object_attributes?.action !== "open" &&
61
+ payload.object_attributes?.action !== "update"
62
+ ) {
63
+ reply.status(200).send({ ignored: true });
64
+ return;
65
+ }
66
+
67
+ const mrUrl = payload.object_attributes?.url;
68
+ if (!mrUrl) {
69
+ reply.status(400).send({ error: "Malformed MR event" });
70
+ return;
71
+ }
72
+
73
+ const jobId = randomUUID();
74
+
75
+ store.create({
76
+ id: jobId,
77
+ type: "review",
78
+ state: "queued",
79
+ input: { target: mrUrl },
80
+ createdAt: Date.now(),
81
+ });
82
+
83
+ runJob(store, logger, jobId, async () =>
84
+ runReviewJob({ target: mrUrl }, config, credentials, logger),
85
+ );
86
+
87
+ logger.info("gitlab.webhook.job_created", { jobId, mrUrl });
88
+
89
+ reply.send({ jobId });
90
+ });
91
+ }
@@ -0,0 +1,31 @@
1
+ type DeliveryEntry = {
2
+ id: string;
3
+ timestamp: number;
4
+ };
5
+
6
+ export function createDeliveryRegistry(ttlMs = 60 * 60 * 1000) {
7
+ const deliveries = new Map<string, DeliveryEntry>();
8
+
9
+ function cleanup() {
10
+ const now = Date.now();
11
+ for (const [key, entry] of deliveries) {
12
+ if (now - entry.timestamp > ttlMs) {
13
+ deliveries.delete(key);
14
+ }
15
+ }
16
+ }
17
+
18
+ return {
19
+ has(id: string): boolean {
20
+ cleanup();
21
+ return deliveries.has(id);
22
+ },
23
+
24
+ register(id: string): void {
25
+ deliveries.set(id, {
26
+ id,
27
+ timestamp: Date.now(),
28
+ });
29
+ },
30
+ };
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ import Fastify from "fastify";
2
+ import fastifyRawBody from "fastify-raw-body";
3
+
4
+ import { bootstrapDaemon } from "./bootstrap.js";
5
+ import { createDaemonLogger } from "./logger.js";
6
+ import { createJobStore } from "./jobs/store.js";
7
+ import { registerJobRoutes } from "./http/jobs.js";
8
+ import { registerHealthRoutes } from "./http/health.js";
9
+ import { setupGracefulShutdown } from "./shutdown.js";
10
+ import { registerGitHubWebhook } from "./http/webhooks/github.js";
11
+ import { registerGitLabWebhook } from "./http/webhooks/gitlab.js";
12
+ import { createDeliveryRegistry } from "./idempotency/deliveryRegistry.js";
13
+
14
+ const deliveryRegistry = createDeliveryRegistry();
15
+
16
+ async function main() {
17
+ const logger = createDaemonLogger();
18
+ const { config, credentials } = bootstrapDaemon();
19
+
20
+ logger.info("daemon.starting", {
21
+ mode: config.mode,
22
+ llm: config.llm.provider,
23
+ embeddings: config.embeddings.provider,
24
+ });
25
+
26
+ const app = Fastify({ logger: false, bodyLimit: 10 * 1024 * 1024 }); // use our logger instead
27
+
28
+ await app.register(fastifyRawBody, {
29
+ field: "rawBody",
30
+ global: false,
31
+ encoding: false,
32
+ });
33
+
34
+ const jobStore = createJobStore(logger);
35
+
36
+ setupGracefulShutdown({
37
+ closeHttp: async () => {
38
+ logger.info("daemon.http.closing");
39
+ await app.close();
40
+ },
41
+ onShutdown: async () => {
42
+ logger.info("daemon.draining.jobs");
43
+ await jobStore.drain();
44
+ },
45
+ });
46
+
47
+ registerHealthRoutes(app, logger, config);
48
+ registerJobRoutes(app, jobStore, config, credentials, logger);
49
+ registerGitHubWebhook(
50
+ app,
51
+ jobStore,
52
+ config,
53
+ credentials,
54
+ logger,
55
+ deliveryRegistry,
56
+ );
57
+ registerGitLabWebhook(
58
+ app,
59
+ jobStore,
60
+ config,
61
+ credentials,
62
+ logger,
63
+ deliveryRegistry,
64
+ );
65
+
66
+ const port = parseInt(process.env.PRSENSE_DAEMON_PORT ?? "11000");
67
+ await app.listen({ port: port });
68
+
69
+ logger.info("daemon.started", { port });
70
+ }
71
+
72
+ main().catch((err) => {
73
+ console.error("Daemon startup failed:", err);
74
+ process.exit(1);
75
+ });
@@ -0,0 +1,29 @@
1
+ import { runIndexWorkflow } from "@prsense/workflows";
2
+ import { createEventBus, PRSENSE_VERSION } from "@prsense/core";
3
+ import type { ResolvedConfig, CredentialContext } from "@prsense/config";
4
+ import type { Logger } from "@prsense/logging";
5
+ import type { IndexJobInput } from "./types.js";
6
+
7
+ export async function runIndexJob(
8
+ input: IndexJobInput,
9
+ config: ResolvedConfig,
10
+ credentials: CredentialContext,
11
+ logger: Logger,
12
+ ) {
13
+ const eventBus = createEventBus((event) => {
14
+ logger.info("domain.event", {
15
+ event: event.event,
16
+ fields: event.fields,
17
+ });
18
+ });
19
+
20
+ return runIndexWorkflow({
21
+ config,
22
+ credentials,
23
+ target: input.target,
24
+ ...(input.force !== undefined ? { force: input.force } : {}),
25
+ ...(input.dryRun !== undefined ? { dryRun: input.dryRun } : {}),
26
+ eventBus,
27
+ version: PRSENSE_VERSION,
28
+ });
29
+ }
@@ -0,0 +1,6 @@
1
+ // apps/daemon/src/jobs/index/types.ts
2
+ export type IndexJobInput = {
3
+ target: string;
4
+ force?: boolean;
5
+ dryRun?: boolean;
6
+ };
@@ -0,0 +1,126 @@
1
+ // apps/daemon/src/jobs/review/runReviewJob.ts
2
+ import { runReviewWorkflow } from "@prsense/workflows";
3
+ import { createEventBus } from "@prsense/core";
4
+ import {
5
+ LocalGitDiffProvider,
6
+ GitHubPrDiffProvider,
7
+ GitLabMrDiffProvider,
8
+ } from "@prsense/context";
9
+ import type { ResolvedConfig, CredentialContext } from "@prsense/config";
10
+ import type { Logger } from "@prsense/logging";
11
+ import type { ReviewJobInput } from "./types.js";
12
+ import { createReporter } from "@prsense/reporters";
13
+
14
+ export async function runReviewJob(
15
+ input: ReviewJobInput,
16
+ config: ResolvedConfig,
17
+ credentials: CredentialContext,
18
+ logger: Logger,
19
+ ) {
20
+ const eventBus = createEventBus((event) => {
21
+ logger.info("domain.event", {
22
+ event: event.event,
23
+ fields: event.fields,
24
+ });
25
+ });
26
+
27
+ let diffProvider;
28
+
29
+ // -------------------------------------------------
30
+ // Determine Provider
31
+ // -------------------------------------------------
32
+
33
+ const githubMatch = input.target.match(
34
+ /github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/,
35
+ );
36
+
37
+ if (githubMatch) {
38
+ const owner = githubMatch[1];
39
+ const repo = githubMatch[2];
40
+ const pr = githubMatch[3];
41
+
42
+ if (!owner || !repo || !pr) {
43
+ throw new Error("Invalid GitHub PR URL");
44
+ }
45
+
46
+ diffProvider = new GitHubPrDiffProvider(
47
+ owner,
48
+ repo.replace(".git", ""),
49
+ pr,
50
+ );
51
+ } else {
52
+ const gitlabMatch = input.target.match(
53
+ /gitlab\.com\/([^\/]+)\/([^\/]+)\/-\/merge_requests\/(\d+)/,
54
+ );
55
+
56
+ if (gitlabMatch) {
57
+ const group = gitlabMatch[1];
58
+ const project = gitlabMatch[2];
59
+ const mr = gitlabMatch[3];
60
+
61
+ if (!group || !project || !mr) {
62
+ throw new Error("Invalid GitLab MR URL");
63
+ }
64
+
65
+ diffProvider = new GitLabMrDiffProvider(
66
+ group,
67
+ project.replace(".git", ""),
68
+ mr,
69
+ );
70
+ } else {
71
+ // Fallback: local repository
72
+ diffProvider = new LocalGitDiffProvider(input.target, input.baseBranch);
73
+ }
74
+ }
75
+
76
+ // -------------------------------------------------
77
+ // Execute Review Workflow
78
+ // -------------------------------------------------
79
+
80
+ const result = await runReviewWorkflow({
81
+ config,
82
+ credentials,
83
+ diffProvider,
84
+ eventBus,
85
+ });
86
+
87
+ // -------------------------------------------------
88
+ // Delivery (Daemon Mode Only)
89
+ // -------------------------------------------------
90
+
91
+ if (
92
+ result.outcome === "success" &&
93
+ config.mode === "daemon" &&
94
+ "delivery" in config &&
95
+ result.payload.signals.length > 0
96
+ ) {
97
+ const platform = config.delivery.platform;
98
+
99
+ const reporter = createReporter(platform, credentials);
100
+
101
+ if (!reporter) {
102
+ logger.warn("delivery.reporter_not_available", {
103
+ provider: platform,
104
+ });
105
+ } else {
106
+ try {
107
+ await reporter.deliver(result.payload.signals, {
108
+ targetUrl: input.target,
109
+ repositoryProvider: platform,
110
+ });
111
+
112
+ logger.info("delivery.completed", {
113
+ provider: platform,
114
+ signalCount: result.payload.signals.length,
115
+ });
116
+ } catch (err) {
117
+ logger.error("delivery.failed", {
118
+ provider: platform,
119
+ error: err instanceof Error ? err.message : String(err),
120
+ });
121
+ }
122
+ }
123
+ }
124
+
125
+ return result;
126
+ }
@@ -0,0 +1,5 @@
1
+ // apps/daemon/src/jobs/review/types.ts
2
+ export type ReviewJobInput = {
3
+ target: string; // local path OR PR/MR URL
4
+ baseBranch?: string;
5
+ };
@@ -0,0 +1,47 @@
1
+ import type { JobStore } from "./store.js";
2
+ import type { Logger } from "@prsense/logging";
3
+
4
+ export async function runJob<TResult>(
5
+ store: JobStore,
6
+ logger: Logger,
7
+ jobId: string,
8
+ fn: () => Promise<TResult>,
9
+ ) {
10
+ const promise = (async () => {
11
+ logger.info("job.execution.started", { jobId });
12
+ store.update(jobId, {
13
+ state: "running",
14
+ startedAt: Date.now(),
15
+ });
16
+
17
+ try {
18
+ const result = await fn();
19
+
20
+ store.update(jobId, {
21
+ state: "completed",
22
+ result,
23
+ finishedAt: Date.now(),
24
+ });
25
+
26
+ logger.info("job.execution.completed", { jobId });
27
+ return result;
28
+ } catch (err) {
29
+ const message = err instanceof Error ? err.message : "Unknown error";
30
+
31
+ store.update(jobId, {
32
+ state: "failed",
33
+ error: message,
34
+ finishedAt: Date.now(),
35
+ });
36
+
37
+ logger.error("job.execution.failed", {
38
+ jobId,
39
+ error: message,
40
+ });
41
+
42
+ throw err;
43
+ }
44
+ })();
45
+
46
+ return store.track(jobId, promise);
47
+ }