Skip to content

Commit 3334bce

Browse files
authored
feat: ✨ Add Linear integration and notification services (#23)
* Introduced `LinearClient` for managing Linear issues, including fetching, updating, and labeling. * Implemented email notification functionality via `sendTaskOutcomeEmail` and `buildTaskOutcomeEmailPayload`. * Created utility functions for generating prompts and comments related to planning, implementation, and review processes. * Added logging and shell command utilities for better error handling and command execution. * Enhanced tests to cover new features and ensure reliability across the application.
1 parent 5d15b8b commit 3334bce

31 files changed

Lines changed: 674 additions & 134 deletions

ARCHITECTURE.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ ADHD.ai is a multi-project orchestration hub that pulls eligible Linear issues a
66

77
## Ownership Boundaries
88

9-
1. `src/config.ts` is the only runtime config resolver for env vars and config files.
10-
2. `src/workflow.ts` owns stage transitions, retries, and orchestration order.
11-
3. Integration modules stay isolated:
12-
- `src/linear.ts`
13-
- `src/github.ts`
14-
- `src/codex.ts`
15-
4. `src/state.ts` owns run-state paths and legacy fallback behavior.
16-
5. `src/args.ts` and `src/index.ts` own CLI parsing and command dispatch.
9+
1. `src/core/config.ts` is the only runtime config resolver for env vars and config files.
10+
2. `src/core/workflow.ts` owns stage transitions, retries, and orchestration order.
11+
3. Integration modules stay isolated under `src/services/`:
12+
- `src/services/linear.ts`
13+
- `src/services/github.ts`
14+
- `src/services/codex.ts`
15+
- `src/services/cron.ts`
16+
- `src/services/notifications.ts`
17+
4. `src/core/state.ts` owns run-state paths and legacy fallback behavior.
18+
5. `src/args.ts` and `src/index.ts` own CLI parsing and command dispatch with command handlers in `src/commands/`.
1719

1820
## Stage Model
1921

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ flowchart LR
5353
Configuration is loaded from `adhd-ai.config.ts` and resolved into project-specific runtime settings. Existing `piv-loop.config.ts` files are still accepted as a legacy fallback.
5454

5555
- Root defaults can define shared repo, linear, codex, skills, and dry-run behavior.
56+
- Optional root `notifications.email` settings can enable terminal outcome emails through Resend.
5657
- Polling is a single global config at the root `polling` key (`intervalMs`, `maxCycles`, `exitWhenIdle`, `staleRunTimeoutMs`) and applies to all selected projects in a run.
5758
- Optional `linear.projectId` can scope each ADHD.ai project to a specific Linear project when selecting assigned work.
5859
- For targeted runs with `--all-projects --issue <KEY>`, ADHD.ai routes the issue to exactly one project by matching `linear.projectId` to the Linear issue's `projectId`.
@@ -132,6 +133,23 @@ Supported schedules:
132133
- `daily`: `{ frequency: "daily", time: "HH:mm" }`
133134
- `weekly`: `{ frequency: "weekly", dayOfWeek: "sun"|"mon"|...|"sat", time: "HH:mm" }`
134135

136+
## Email Notifications
137+
138+
Email notifications are optional and global. ADHD.ai sends a notification when an issue reaches a terminal outcome (`done` or `blocked`).
139+
140+
Configuration options:
141+
142+
- `notifications.email.enabled` (optional boolean; defaults to auto-enabled when a Resend API key exists)
143+
- `notifications.email.resendApiKey`
144+
- `notifications.email.from`
145+
- `notifications.email.to` (array of recipients)
146+
147+
Environment fallbacks:
148+
149+
- `RESEND_API_KEY`
150+
- `RESEND_FROM`
151+
- `RESEND_TO` (comma-separated recipients)
152+
135153
## Required Environment
136154

137155
Set these variables before running:
@@ -174,6 +192,9 @@ The `PIV_*` environment variable namespace remains supported for compatibility w
174192
- `CODEX_HOME` to override Codex runtime state directory
175193
- `PIV_LOG_LEVEL` (optional; default `info`)
176194
- `PIV_LOG_PRETTY` (optional; default `1` in TTY, `0` otherwise)
195+
- `RESEND_API_KEY` (optional; enables email notifications when configured with sender/recipients)
196+
- `RESEND_FROM` (optional; required when email notifications are enabled)
197+
- `RESEND_TO` (optional; comma-separated recipients, required when email notifications are enabled)
177198

178199
`LINEAR_STATUS_*` values may be either Linear workflow state IDs or exact state names (for example `Todo`, `In Progress`, `Done`). Names are resolved to IDs at runtime.
179200

adhd-ai.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from "node:path";
2-
import type { AdhdAiRootConfig, DeepPartial } from "./src/types";
2+
import type { AdhdAiRootConfig, DeepPartial } from "./src/core/types";
33

44
const cwd = process.cwd();
55

src/args.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RunOptions } from "./types";
1+
import type { RunOptions } from "./core/types";
22

33
export type CliCommand =
44
| { kind: "run"; options: RunOptions }

src/commands/handlers.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { CliCommand } from "../args";
2+
import type { LoadedConfig } from "../core/config";
3+
import { getProjectById } from "../core/config";
4+
import { loadRunState, normalizeIssueKey } from "../core/state";
5+
import { runWorkflow } from "../core/workflow";
6+
import { runCronScheduler } from "../services/cron";
7+
8+
type RunnableCommand = Exclude<CliCommand, { kind: "help" }>;
9+
10+
export async function handleCommand(
11+
command: RunnableCommand,
12+
config: LoadedConfig,
13+
): Promise<void> {
14+
if (command.kind === "run") {
15+
await runWorkflow(config, command.options);
16+
return;
17+
}
18+
19+
if (command.kind === "cron") {
20+
await runCronScheduler(config, { jobId: command.jobId });
21+
return;
22+
}
23+
24+
if (command.kind === "projects") {
25+
for (const project of config.projects) {
26+
process.stdout.write(
27+
`${[
28+
project.id,
29+
project.name,
30+
`exec=${project.executionPath}`,
31+
`state=${project.workspacePath}`,
32+
].join("\t")}\n`,
33+
);
34+
}
35+
return;
36+
}
37+
38+
const project = getProjectById(config, command.projectId);
39+
if (!project) {
40+
throw new Error(`Project '${command.projectId}' not found`);
41+
}
42+
const key = normalizeIssueKey(command.issueKey);
43+
const state = await loadRunState(project.workspacePath, project.id, key);
44+
if (!state) {
45+
process.stdout.write(
46+
`No run state found for ${key} in project ${project.id}\n`,
47+
);
48+
return;
49+
}
50+
process.stdout.write(`${JSON.stringify(state, null, 2)}\n`);
51+
}
52+
53+
export function printHelp(): void {
54+
process.stdout.write(
55+
`${[
56+
"adhd-ai - Agent-Driven Development Hub (ADHD.ai) CLI orchestration workflow",
57+
"",
58+
"Commands:",
59+
" adhd-ai run [--project <PROJECT_ID>] [--issue <LINEAR_KEY_OR_URL>] [--poll] [--no-exit-when-idle] [--poll-interval-ms <MS>] [--max-poll-cycles <N>]",
60+
" adhd-ai run --all-projects [--issue <LINEAR_KEY_OR_URL>] [--poll] [--no-exit-when-idle]",
61+
" adhd-ai cron [--job <JOB_ID>]",
62+
" adhd-ai status --project <PROJECT_ID> --issue <LINEAR_KEY>",
63+
" adhd-ai projects",
64+
" adhd-ai help",
65+
"",
66+
"Environment:",
67+
" LINEAR_API_KEY, LINEAR_STATUS_* state IDs, GITHUB_* repo settings",
68+
].join("\n")}\n`,
69+
);
70+
}

src/config.ts renamed to src/core/config.ts

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import type {
88
CronJobSchedule,
99
CronScheduleDayOfWeek,
1010
DeepPartial,
11+
NotificationConfig,
1112
PollingConfig,
1213
ProjectConfig,
1314
ProjectRuntimeConfig,
15+
ResolvedNotificationConfig,
1416
ResolvedProjectConfig,
1517
RunOptions,
1618
} from "./types";
@@ -26,21 +28,29 @@ export interface LoadedConfig {
2628
projects: ResolvedProjectConfig[];
2729
polling: PollingConfig;
2830
cron: CronConfig;
31+
notifications: ResolvedNotificationConfig;
2932
}
3033

3134
export async function loadConfig(cwd: string): Promise<LoadedConfig> {
3235
const envBase = buildEnvBase(cwd);
3336
const envPolling = buildEnvPolling();
37+
const envNotifications = buildEnvNotifications();
3438
const loadedOverride = await loadConfigOverride(cwd);
3539
const root = normalizeOverrideToRoot(loadedOverride);
3640
assertNoProjectPolling(root.projects);
41+
assertNoProjectNotifications(root.projects);
3742
const projects = resolveProjects(envBase, root);
3843
const polling = resolvePolling(envPolling, root.polling);
3944
const cron = resolveCron(root.cron);
45+
const notifications = resolveNotifications(
46+
envNotifications,
47+
root.notifications,
48+
);
4049
validateProjects(projects);
4150
validatePolling(polling);
4251
validateCron(cron);
43-
return { projects, polling, cron };
52+
validateNotifications(notifications);
53+
return { projects, polling, cron, notifications };
4454
}
4555

4656
export function getProjectById(
@@ -122,6 +132,18 @@ function buildEnvPolling(): PollingConfig {
122132
};
123133
}
124134

135+
function buildEnvNotifications(): ResolvedNotificationConfig {
136+
const env = process.env;
137+
return {
138+
email: {
139+
enabled: false,
140+
resendApiKey: normalizeOptionalValue(env.RESEND_API_KEY),
141+
from: normalizeOptionalValue(env.RESEND_FROM),
142+
to: parseRecipientsFromEnv(env.RESEND_TO),
143+
},
144+
};
145+
}
146+
125147
async function loadConfigOverride(cwd: string): Promise<AnyOverride> {
126148
for (const configFile of [DEFAULT_CONFIG_FILE, LEGACY_CONFIG_FILE]) {
127149
const configPath = path.join(cwd, configFile);
@@ -171,7 +193,13 @@ function resolveProjects(
171193
function stripProjects(
172194
root: AdhdAiRootConfig,
173195
): DeepPartial<ProjectRuntimeConfig> {
174-
const { projects: _, polling: __, cron: ___, ...rest } = root;
196+
const {
197+
projects: _,
198+
polling: __,
199+
cron: ___,
200+
notifications: ____,
201+
...rest
202+
} = root;
175203
return rest;
176204
}
177205

@@ -194,6 +222,77 @@ function resolveCron(
194222
};
195223
}
196224

225+
function resolveNotifications(
226+
base: ResolvedNotificationConfig,
227+
override: DeepPartial<NotificationConfig> | undefined,
228+
): ResolvedNotificationConfig {
229+
const email = override?.email;
230+
const resendApiKey =
231+
typeof email?.resendApiKey === "string"
232+
? normalizeOptionalValue(email.resendApiKey)
233+
: base.email.resendApiKey;
234+
const from =
235+
typeof email?.from === "string"
236+
? normalizeOptionalValue(email.from)
237+
: base.email.from;
238+
const to = normalizeRecipientsOverride(email?.to) ?? base.email.to;
239+
const enabled = resolveNotificationEnabled(email?.enabled, resendApiKey);
240+
241+
return {
242+
email: {
243+
enabled,
244+
resendApiKey,
245+
from,
246+
to,
247+
},
248+
};
249+
}
250+
251+
function resolveNotificationEnabled(
252+
input: unknown,
253+
resendApiKey: string | undefined,
254+
): boolean {
255+
if (input === undefined) {
256+
return Boolean(resendApiKey);
257+
}
258+
if (input === true) {
259+
return true;
260+
}
261+
if (input === false) {
262+
return false;
263+
}
264+
throw new Error("notifications.email.enabled must be a boolean");
265+
}
266+
267+
function normalizeRecipientsOverride(input: unknown): string[] | undefined {
268+
if (input === undefined) {
269+
return undefined;
270+
}
271+
if (!Array.isArray(input)) {
272+
throw new Error("notifications.email.to must be an array of email strings");
273+
}
274+
275+
const recipients = input.map((value, index) => {
276+
if (typeof value !== "string") {
277+
throw new Error(
278+
`notifications.email.to[${index}] must be an email string`,
279+
);
280+
}
281+
return value.trim();
282+
});
283+
return recipients.filter((recipient) => recipient.length > 0);
284+
}
285+
286+
function parseRecipientsFromEnv(input: string | undefined): string[] {
287+
if (!input) {
288+
return [];
289+
}
290+
return input
291+
.split(",")
292+
.map((value) => value.trim())
293+
.filter((value) => value.length > 0);
294+
}
295+
197296
function resolveCronJob(
198297
job: DeepPartial<CronJobConfig>,
199298
index: number,
@@ -555,6 +654,30 @@ function validateCron(cron: CronConfig): void {
555654
}
556655
}
557656

657+
function validateNotifications(
658+
notifications: ResolvedNotificationConfig,
659+
): void {
660+
const { email } = notifications;
661+
if (!email.enabled) {
662+
return;
663+
}
664+
if (!email.resendApiKey) {
665+
throw new Error(
666+
"notifications.email.resendApiKey (or RESEND_API_KEY) is required when email notifications are enabled",
667+
);
668+
}
669+
if (!email.from) {
670+
throw new Error(
671+
"notifications.email.from (or RESEND_FROM) is required when email notifications are enabled",
672+
);
673+
}
674+
if (email.to.length === 0) {
675+
throw new Error(
676+
"notifications.email.to (or RESEND_TO) must include at least one recipient when email notifications are enabled",
677+
);
678+
}
679+
}
680+
558681
function validateCronSchedule(jobId: string, schedule: CronJobSchedule): void {
559682
if (schedule.frequency === "minute") {
560683
const every = schedule.every ?? 1;
@@ -636,3 +759,13 @@ function assertNoProjectPolling(projects: ProjectConfig[]): void {
636759
}
637760
}
638761
}
762+
763+
function assertNoProjectNotifications(projects: ProjectConfig[]): void {
764+
for (const project of projects) {
765+
if ("notifications" in (project as unknown as Record<string, unknown>)) {
766+
throw new Error(
767+
`Project-level notifications config is not supported for project '${project.id}'. Configure notifications once at root level.`,
768+
);
769+
}
770+
}
771+
}
File renamed without changes.
File renamed without changes.

src/types.ts renamed to src/core/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,32 @@ export interface CronConfig {
148148
jobs: CronJobConfig[];
149149
}
150150

151+
export interface NotificationEmailConfig {
152+
enabled?: boolean;
153+
resendApiKey?: string;
154+
from?: string;
155+
to?: string[];
156+
}
157+
158+
export interface NotificationConfig {
159+
email?: NotificationEmailConfig;
160+
}
161+
162+
export interface ResolvedNotificationEmailConfig {
163+
enabled: boolean;
164+
resendApiKey?: string;
165+
from?: string;
166+
to: string[];
167+
}
168+
169+
export interface ResolvedNotificationConfig {
170+
email: ResolvedNotificationEmailConfig;
171+
}
172+
151173
export type AdhdAiRootConfig = DeepPartial<ProjectRuntimeConfig> & {
152174
polling?: DeepPartial<PollingConfig>;
153175
cron?: DeepPartial<CronConfig>;
176+
notifications?: DeepPartial<NotificationConfig>;
154177
projects: ProjectConfig[];
155178
};
156179

0 commit comments

Comments
 (0)