From 502029e4d79d83dca22c8c19a59f97fe1d7744d6 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Thu, 3 Sep 2026 17:41:33 +0200 Subject: [PATCH 1/2] feat: add repository goals and social plans --- Dockerfile | 6 +- README.md | 54 ++- docker-compose.yml | 9 +- package-lock.json | 434 +++++++++++++++++- package.json | 4 +- src/App.tsx | 94 +++- src/api/github.ts | 53 +++ src/components/SidebarControls.tsx | 2 +- src/components/TopBar.tsx | 15 +- src/components/common/Icons.tsx | 24 + src/components/common/RepositoryPicker.tsx | 93 ++++ src/components/modals/GoalProposalsModal.tsx | 179 ++++++++ .../modals/RepositoryDetailsModal.tsx | 2 +- .../preferences/AiIntegrationSettings.tsx | 268 +++++++++++ src/components/views/DailyDigestView.tsx | 2 +- src/components/views/GoalsLoadingState.tsx | 51 ++ src/components/views/GoalsView.tsx | 257 +++++++++++ src/components/views/PreferencesView.tsx | 134 ++++++ src/i18n/en.ts | 95 ++++ src/i18n/it.ts | 95 ++++ src/server/ai/client.ts | 212 +++++++++ src/server/ai/providers.ts | 83 ++++ src/server/ai/settings.ts | 180 ++++++++ src/server/aiDigest.ts | 83 ++++ src/server/digests.ts | 6 +- src/server/goalStore.ts | 114 +++++ src/server/goals.ts | 257 +++++++++++ src/server/openaiDigest.ts | 118 ----- src/server/preferenceStore.ts | 46 ++ src/server/routes/ai.ts | 58 +++ src/server/routes/goals.ts | 100 ++++ src/server/routes/index.ts | 4 + src/server/spa.ts | 2 + src/server/sqlite.ts | 38 ++ src/styles.css | 1 + src/styles/goals.css | 198 ++++++++ src/styles/preferences.css | 162 +++++++ src/types/ai.ts | 49 ++ src/types/github.ts | 2 + src/types/goals.ts | 61 +++ src/utils/dataRequirements.ts | 3 +- src/utils/digests.ts | 1 + src/utils/goals.ts | 49 ++ src/utils/socialProposals.ts | 80 ++++ tests/server/aiClient.test.ts | 112 +++++ tests/server/aiSettings.test.ts | 112 +++++ tests/utils/goals.test.ts | 54 +++ tests/utils/socialProposals.test.ts | 35 ++ 48 files changed, 3930 insertions(+), 161 deletions(-) create mode 100644 src/components/common/RepositoryPicker.tsx create mode 100644 src/components/modals/GoalProposalsModal.tsx create mode 100644 src/components/preferences/AiIntegrationSettings.tsx create mode 100644 src/components/views/GoalsLoadingState.tsx create mode 100644 src/components/views/GoalsView.tsx create mode 100644 src/components/views/PreferencesView.tsx create mode 100644 src/server/ai/client.ts create mode 100644 src/server/ai/providers.ts create mode 100644 src/server/ai/settings.ts create mode 100644 src/server/aiDigest.ts create mode 100644 src/server/goalStore.ts create mode 100644 src/server/goals.ts delete mode 100644 src/server/openaiDigest.ts create mode 100644 src/server/preferenceStore.ts create mode 100644 src/server/routes/ai.ts create mode 100644 src/server/routes/goals.ts create mode 100644 src/server/sqlite.ts create mode 100644 src/styles/goals.css create mode 100644 src/types/ai.ts create mode 100644 src/types/goals.ts create mode 100644 src/utils/goals.ts create mode 100644 src/utils/socialProposals.ts create mode 100644 tests/server/aiClient.test.ts create mode 100644 tests/server/aiSettings.test.ts create mode 100644 tests/utils/goals.test.ts create mode 100644 tests/utils/socialProposals.test.ts diff --git a/Dockerfile b/Dockerfile index 4508e58..162abc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG NODE_BUILDER_VERSION=22-slim -ARG NODE_RUNTIME_VERSION=22-alpine +ARG NODE_RUNTIME_VERSION=22-slim FROM node:${NODE_BUILDER_VERSION} AS builder WORKDIR /app @@ -12,7 +12,8 @@ COPY CHANGELOG.md ./ COPY src ./src COPY public ./public -RUN npm run build +RUN npm run build \ + && npm prune --omit=dev FROM node:${NODE_RUNTIME_VERSION} AS runtime @@ -23,6 +24,7 @@ ENV NODE_ENV=production \ PORT=8765 COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/index.html ./index.html COPY docker-entrypoint.sh ./docker-entrypoint.sh diff --git a/README.md b/README.md index 08de6d7..6e79b67 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ The dashboard pulls data from the GitHub REST and GraphQL APIs and organizes it - **Issues / Pull Requests** — cross-repo lists with the same filter sidebar, useful for triage across many projects. - **Insights** — overview of all repos with alerts ("issues need attention", "security alerts need attention", "no push for X days"), opportunities, and correlations between traffic and recent activity. Each repo gets a status (Strong / Watch / Risky). - **Alerts** — dedicated security-alert view for Dependabot and code scanning findings, so you can jump straight to the repos that need attention. -- **Daily digest** — short per-repo summary of the day's movement (stars, forks, issues), with an executive summary you can copy as Markdown. Optionally augmented by an OpenAI-generated narrative when `OPENAI_API_KEY` is configured. +- **Daily digest** — short per-repo summary of the day's movement (stars, forks, issues), with an executive summary you can copy as Markdown. Optionally augmented by an AI-generated narrative when an [AI provider](#ai-integration) is configured. - **Board** — Kanban-style view that groups issues into columns (Backlog, To-do, In progress, Ready, In review, etc.). +- **Goals** — persistent repository targets for stars, forks, closed PRs, and release downloads, with progress tracking and activity-aware AI action plans (including social post ideas). ### Per-repository view @@ -72,7 +73,7 @@ UI translations live in `src/i18n/`, with one dictionary file per language. See - **Node.js 20+** (anything that supports native `fetch` and ESM is fine). - A **GitHub OAuth App** with **Device Flow enabled** (see next section). -- (Optional) An **OpenAI API key** if you want AI-generated daily digest summaries. +- (Optional) An API key for **OpenAI, Anthropic, Google Gemini, OpenRouter or any OpenAI-compatible endpoint** if you want AI-generated digest summaries and Goals action plans (see [AI integration](#ai-integration)). ## Configure GitHub @@ -127,14 +128,41 @@ The server reads its configuration from environment variables: | `GITHUB_TOKEN` | only `token` | — | Personal access token used when `GH_AUTH_MODE=token` | | `HOST` | no | `127.0.0.1` | Interface the server binds to | | `PORT` | no | `8765` | Port the server listens on | -| `OPENAI_API_KEY` | no | — | Enables AI-generated daily digest narratives | +| `AI_PROVIDER` | no | auto-detected | AI provider: `openai`, `anthropic`, `gemini`, `openrouter` or `custom`. When unset, the first provider with a key in the environment is used | +| `OPENAI_API_KEY` | no | — | OpenAI key (also enables the provider when `AI_PROVIDER` is unset) | +| `ANTHROPIC_API_KEY` | no | — | Anthropic key | +| `GEMINI_API_KEY` | no | — | Google Gemini key (`GOOGLE_API_KEY` is accepted too) | +| `OPENROUTER_API_KEY` | no | — | OpenRouter key | +| `AI_API_KEY` | no | — | Generic key for the provider selected with `AI_PROVIDER` (required for `custom` endpoints that need one) | +| `AI_MODEL` | no | per provider | Model for the provider selected with `AI_PROVIDER`. Per-provider aliases: `OPENAI_MODEL`, `ANTHROPIC_MODEL`, `GEMINI_MODEL`, `OPENROUTER_MODEL` | +| `AI_BASE_URL` | no | per provider | Endpoint override for the provider selected with `AI_PROVIDER`, e.g. `http://localhost:11434/v1` for Ollama with `AI_PROVIDER=custom` | | `GITLAB_CLIENT_ID` | no | — | Enables GitLab OAuth when paired with `GITLAB_CLIENT_SECRET` | | `GITLAB_CLIENT_SECRET` | no | — | OAuth application secret for the selected GitLab instance | | `GITLAB_REDIRECT_URI` | no | inferred from request | Exact GitLab OAuth callback URL, ending in `/api/auth/gitlab/callback` | | `GITLAB_OAUTH_INSTANCE_URL` | no | `https://gitlab.com` | GitLab instance on which the configured OAuth app is registered | -| `OPENAI_DIGEST_MODEL` | no | `gpt-4.1-mini` | Model used for digest narratives | +| `OPENAI_DIGEST_MODEL` | no | — | Legacy alias of `OPENAI_MODEL`, still honoured | | `GITDECK_DIAGNOSTICS` | no | — | Set to `1` to log provider call durations | +### AI integration + +Digest narratives and Goals action plans are generated by a pluggable AI provider. Supported providers and their default models: + +| Provider | `AI_PROVIDER` | Key variable | Default model | Default endpoint | +| ------------ | ------------- | --------------------- | --------------------- | ---------------- | +| OpenAI | `openai` | `OPENAI_API_KEY` | `gpt-4.1-mini` | `https://api.openai.com/v1` | +| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-5` | `https://api.anthropic.com` | +| Google Gemini| `gemini` | `GEMINI_API_KEY` | `gemini-2.5-flash` | `https://generativelanguage.googleapis.com/v1beta` | +| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openai/gpt-4.1-mini` | `https://openrouter.ai/api/v1` | +| OpenAI-compatible (Ollama, Mistral, Groq, LM Studio…) | `custom` | `AI_API_KEY` (optional) | — (set `AI_MODEL`) | `http://localhost:11434/v1` | + +The configuration is layered: + +1. **Built-in defaults** (model and endpoint per provider). +2. **Environment variables** listed above. +3. **Values saved from the UI** in `~/.gitdeck/gitdeck.sqlite`, which override the environment. + +Open **Preferences → All preferences** (or go to `/preferences`) to pick the provider, store an API key, model or base URL, test the connection, and see for every field whether the value in effect comes from the database, the environment or a default. *Reset to environment* removes every stored override. Keys saved from the UI never leave the server: the API only returns a masked version. + ### Authentication modes The dashboard can obtain a GitHub token in three different ways. Pick the one that fits your setup: @@ -145,7 +173,13 @@ The dashboard can obtain a GitHub token in three different ways. Pick the one th In `gh-cli` and `token` modes the device-flow sign-in screen is hidden; the server treats the configured source as authoritative. -Tokens and snapshots are persisted under `~/.gitdeck/`. If you previously ran an older build that stored data in `~/.gh-issues-dashboard/`, the server migrates it automatically on first start. +Tokens and snapshots are persisted under `~/.gitdeck/`. Goals and server-side preferences are stored in `~/.gitdeck/gitdeck.sqlite`. If you previously ran an older build that stored data in `~/.gh-issues-dashboard/`, the server migrates it automatically on first start. + +### Extending persisted preferences and Goals + +Use `setPreference(scope, key, value)` and `getPreference(scope, key, fallback)` from `src/server/preferenceStore.ts` to persist any JSON-serialisable preference without creating a new schema. Low-level parameterised SQLite helpers are in `src/server/sqlite.ts`. + +To add a Goal metric, add one metadata entry to `GOAL_METRIC_DEFINITIONS` in `src/types/goals.ts` and its resolver to `METRIC_RESOLVERS` in `src/server/goals.ts`. The type, creation UI, persistence, progress UI, and AI context update without further wiring. ### GitLab accounts @@ -218,10 +252,14 @@ With Docker Compose (recommended): ```bash cat > .env <<'EOF' GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx -# Optional — enables AI-generated daily digest narratives +# Optional — enables AI-generated digest narratives and Goals plans (any one provider) OPENAI_API_KEY=sk-... +# ANTHROPIC_API_KEY=... +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... # Optional overrides -# OPENAI_DIGEST_MODEL=gpt-4.1-mini +# AI_PROVIDER=openrouter +# AI_MODEL=anthropic/claude-sonnet-5 # GITHUB_OAUTH_SCOPES=repo read:org project read:user user:email EOF docker compose up -d --build @@ -240,7 +278,7 @@ docker run -d --name gitdeck \ gitdeck ``` -The container forwards `GITHUB_CLIENT_ID`, `GITHUB_OAUTH_SCOPES`, `OPENAI_API_KEY` and `OPENAI_DIGEST_MODEL` from the host environment (or `.env` with Compose) — see [Configuration](#configuration) for the full list. It sets `HOST=0.0.0.0` so the server is reachable from outside. To wipe the stored token (full logout) remove the volume: `docker volume rm gitdeck-data`. +The container forwards `GITHUB_CLIENT_ID`, `GITHUB_OAUTH_SCOPES` and the AI variables (`AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_BASE_URL`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`) from the host environment (or `.env` with Compose) — see [Configuration](#configuration) for the full list. It sets `HOST=0.0.0.0` so the server is reachable from outside. To wipe the stored token (full logout) remove the volume: `docker volume rm gitdeck-data`. ## Test & type-check diff --git a/docker-compose.yml b/docker-compose.yml index 4185829..a6377b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,15 @@ services: environment: GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:?set GITHUB_CLIENT_ID in env or .env} GITHUB_OAUTH_SCOPES: ${GITHUB_OAUTH_SCOPES:-} + AI_PROVIDER: ${AI_PROVIDER:-} + AI_API_KEY: ${AI_API_KEY:-} + AI_MODEL: ${AI_MODEL:-} + AI_BASE_URL: ${AI_BASE_URL:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} - OPENAI_DIGEST_MODEL: ${OPENAI_DIGEST_MODEL:-} + OPENAI_MODEL: ${OPENAI_MODEL:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} volumes: - gitdeck-data:/home/node/.gitdeck diff --git a/package-lock.json b/package-lock.json index f785052..949b42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "gitdeck", "version": "1.0.8", "dependencies": { + "better-sqlite3": "^11.10.0", "find-my-way": "^9.9.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -16,6 +17,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1116,6 +1118,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1422,6 +1434,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1432,6 +1475,50 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -1522,6 +1609,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1788,6 +1881,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1801,7 +1918,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1840,6 +1956,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -1944,6 +2069,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2009,6 +2143,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/find-my-way": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", @@ -2023,6 +2163,12 @@ "node": ">=20" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2061,6 +2207,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -2176,6 +2328,38 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -3460,16 +3644,33 @@ ], "license": "MIT" }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3495,6 +3696,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -3502,6 +3709,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-package-data": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz", @@ -3528,6 +3747,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3622,6 +3850,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -3632,6 +3887,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3642,6 +3907,21 @@ "node": ">=6" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -3728,6 +4008,20 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -3884,6 +4178,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -3929,7 +4243,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3964,6 +4277,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4044,6 +4402,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4086,6 +4453,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -4127,6 +4503,34 @@ "dev": true, "license": "MIT" }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4758,6 +5162,18 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4890,6 +5306,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -5197,6 +5619,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 585ebeb..9a7ebd5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "api": "tsx watch src/server.ts", "dev": "concurrently \"npm:api\" \"vite --host 127.0.0.1\"", - "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node22 --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outfile=dist/server.js && vite build", + "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node22 --external:better-sqlite3 --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outfile=dist/server.js && vite build", "preview": "vite preview --host 127.0.0.1", "serve": "node dist/server.js", "start": "node dist/server.js", @@ -16,6 +16,7 @@ "version": "node scripts/sync-version.js && conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md src/version.ts" }, "dependencies": { + "better-sqlite3": "^11.10.0", "find-my-way": "^9.9.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -24,6 +25,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/App.tsx b/src/App.tsx index 3aab5b7..6a4083f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { fetchAuthStatus, fetchCIHealth, fetchDailyDigests, + fetchGoals, fetchNotifications, fetchRepoInsights, logoutAuth, @@ -20,9 +21,10 @@ import { WelcomeModal } from "./components/modals/WelcomeModal"; import { CommandPalette } from "./components/modals/CommandPalette"; import { Footer } from "./components/Footer"; import { TopBar } from "./components/TopBar"; +import { PreferencesView } from "./components/views/PreferencesView"; import { SidebarControls, type InboxSidebarState } from "./components/SidebarControls"; import { Pagination } from "./components/common/Pagination"; -import { BoardIcon, BookIcon, ExportIcon, InboxIcon, IssueIcon, LoadingIcon, PulseIcon } from "./components/common/Icons"; +import { AlertIcon, BoardIcon, BookIcon, CIIcon, DigestIcon, ExportIcon, GoalIcon, InboxIcon, InsightsIcon, IssueIcon, LoadingIcon, PullRequestIcon } from "./components/common/Icons"; import { IssueList } from "./components/views/IssueList"; import { PullRequestList } from "./components/views/PullRequestList"; import { DailyDigestView } from "./components/views/DailyDigestView"; @@ -31,6 +33,8 @@ import { InsightsView } from "./components/views/InsightsView"; import { RepoGrid } from "./components/views/RepoGrid"; import { KanbanView } from "./components/views/KanbanView"; import { CIHealthView } from "./components/views/CIHealthView"; +import { GoalsView } from "./components/views/GoalsView"; +import type { RepositoryGoal } from "./types/goals"; import type { CIHealthData, DailyDigestEntry, @@ -67,7 +71,7 @@ import { useI18n } from "./i18n/I18nProvider"; import { useAccounts, useCapability } from "./contexts/AccountContext"; import { useDashboardData } from "./hooks/useDashboardData"; -type Tab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests"; +type Tab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "goals"; type Theme = "dark" | "light" | "auto"; type TextSize = "small" | "normal" | "large"; @@ -81,8 +85,11 @@ const TAB_ROUTES: Record = { alerts: "/alerts", ci: "/ci", digests: "/daily", + goals: "/goals", }; +const PREFERENCES_ROUTE = "/preferences"; + const ROUTE_TABS = new Map(Object.entries(TAB_ROUTES).map(([tab, route]) => [route, tab as Tab])); const DETAIL_TABS = new Set(["overview", "actions", "commits", "pull-requests", "issues", "milestones", "releases", "branches", "forks", "traffic", "mentions", "discussions", "dependents"]); const METRIC_KINDS = new Set(["stars", "forks"]); @@ -168,6 +175,9 @@ export function App() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const tab = tabFromPath(location.pathname); + const isPreferencesPage = location.pathname === PREFERENCES_ROUTE; + // `view` is null on the preferences page so no dashboard tab content renders there. + const view: Tab | null = isPreferencesPage ? null : tab; const routeRepoName = searchParams.get("repo") || ""; const repoDetailTab = detailTabFromParams(searchParams); const routeMetricKind = metricKindFromParams(searchParams); @@ -186,6 +196,8 @@ export function App() { const [dailyDigests, setDailyDigests] = useState([]); const [digestPeriod, setDigestPeriod] = useState(() => (localStorage.getItem("gh-dash.digestPeriod") as DigestPeriod) || "day"); const [ciHealth, setCiHealth] = useState([]); + const [goals, setGoals] = useState([]); + const [goalsLoaded, setGoalsLoaded] = useState(false); const [insightsLoaded, setInsightsLoaded] = useState(false); const [ciLoaded, setCiLoaded] = useState(false); const [digestsLoaded, setDigestsLoaded] = useState(false); @@ -228,6 +240,8 @@ export function App() { setRepoInsights([]); setDailyDigests([]); setCiHealth([]); + setGoals([]); + setGoalsLoaded(false); setInsightsLoaded(false); setCiLoaded(false); setDigestsLoaded(false); @@ -279,6 +293,24 @@ export function App() { loadAll(); }, [authState, paletteOpen, loadAll]); + const refreshGoals = useCallback(async () => { + const data = await fetchGoals(); + setGoals(data.goals); + setGoalsLoaded(true); + }, []); + + useEffect(() => { + if (authState !== "authenticated" || tab !== "goals") return; + const controller = new AbortController(); + fetchGoals(controller.signal).then((data) => { + if (!controller.signal.aborted) { + setGoals(data.goals); + setGoalsLoaded(true); + } + }).catch(() => { if (!controller.signal.aborted) setGoalsLoaded(true); }); + return () => controller.abort(); + }, [authState, activeAccountId, tab]); + useEffect(() => { if (authState !== "authenticated") return; if (tab !== "ci") return; @@ -340,6 +372,8 @@ export function App() { setRepoInsights([]); setDailyDigests([]); setCiHealth([]); + setGoals([]); + setGoalsLoaded(false); setInsightsLoaded(false); setCiLoaded(false); setDigestsLoaded(false); @@ -372,8 +406,10 @@ export function App() { document.body.classList.toggle("tab-alerts", tab === "alerts"); document.body.classList.toggle("tab-ci", tab === "ci"); document.body.classList.toggle("tab-digests", tab === "digests"); + document.body.classList.toggle("tab-goals", tab === "goals"); + document.body.classList.toggle("route-preferences", isPreferencesPage); document.body.classList.toggle("filters-open", filtersOpen); - }, [tab, filtersOpen]); + }, [tab, filtersOpen, isPreferencesPage]); useEffect(() => { if (location.pathname === "/" || location.pathname === "/index.html") { @@ -623,7 +659,7 @@ export function App() { const search = tab === "inbox" ? inboxSearch - : tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" + : tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals" ? repoFilters.search : tab === "prs" ? prFilters.search @@ -642,7 +678,7 @@ export function App() { if (tab === "inbox") { setInboxSearch(value); setInboxPage(1); - } else if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests") { + } else if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals") { setRepoFilters({ ...repoFilters, search: value }); setRepoPage(1); } else if (tab === "prs") { @@ -655,7 +691,7 @@ export function App() { } function resetFilters() { - if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests") setRepoFilters(defaultRepoFilters()); + if (tab === "repos" || tab === "insights" || tab === "alerts" || tab === "digests" || tab === "goals") setRepoFilters(defaultRepoFilters()); else if (tab === "prs") setPrFilters(defaultPrFilters()); else setIssueFilters(defaultIssueFilters()); clearFiltersCache(); @@ -694,11 +730,12 @@ export function App() { { key: "inbox" as const, label: t("tabs.inbox"), count: issues.length + pullRequests.length, ready: inboxLoaded, icon: }, { key: "repos" as const, label: t("tabs.repositories"), count: repos.length, ready: reposLoaded, icon: }, { key: "issues" as const, label: t("tabs.issues"), count: issues.length, ready: issuesLoaded, icon: }, - { key: "prs" as const, label: t("tabs.pullRequests"), count: pullRequests.length, ready: prsLoaded, icon: }, - { key: "insights" as const, label: t("tabs.insights"), count: filteredInsights.length, ready: insightsLoaded, icon: }, - { key: "alerts" as const, label: t("tabs.alerts"), count: totalSecurityAlerts, ready: insightsLoaded, icon: }, - { key: "ci" as const, label: t("tabs.ci"), count: ciHealth.length, ready: ciLoaded, icon: }, - { key: "digests" as const, label: t("tabs.digest"), count: dailyDigests.length, ready: digestsLoaded, icon: }, + { key: "prs" as const, label: t("tabs.pullRequests"), count: pullRequests.length, ready: prsLoaded, icon: }, + { key: "insights" as const, label: t("tabs.insights"), count: filteredInsights.length, ready: insightsLoaded, icon: }, + { key: "alerts" as const, label: t("tabs.alerts"), count: totalSecurityAlerts, ready: insightsLoaded, icon: }, + { key: "ci" as const, label: t("tabs.ci"), count: ciHealth.length, ready: ciLoaded, icon: }, + { key: "digests" as const, label: t("tabs.digest"), count: dailyDigests.length, ready: digestsLoaded, icon: }, + { key: "goals" as const, label: t("tabs.goals"), count: goals.length, ready: goalsLoaded, icon: }, ...(projectsEnabled ? [{ key: "kanban" as const, label: t("tabs.board"), count: boardCount, ready: boardLoaded, icon: }] : []), @@ -722,6 +759,8 @@ export function App() { onRefresh={() => loadData(dataRequirementsForTab(tab, Boolean(routeRepoName), repoDetailTab), true)} onOpenFilters={() => setFiltersOpen(true)} onOpenPalette={() => setPaletteOpen(true)} + onOpenPreferencesPage={() => navigate(PREFERENCES_ROUTE)} + preferencesPageActive={isPreferencesPage} onLogout={() => void handleLogout()} canLogout={authMode === "device"} /> @@ -749,6 +788,18 @@ export function App() { />
{error ?
{error}
: null} + {isPreferencesPage ? ( + navigate(TAB_ROUTES[tab])} + /> + ) : null} + {view ? (
{tabs.map((item) => ( @@ -762,8 +813,9 @@ export function App() { ))}
+ ) : null} - {tab === "inbox" ? ( + {view === "inbox" ? ( ) : null} - {tab === "issues" ? ( + {view === "issues" ? (
{t("stats.openIssues")}
{countText(filteredIssues.length, issuesLoaded)}
{t("stats.matchingFilters")}
@@ -807,7 +859,7 @@ export function App() {
) : null} - {tab === "prs" ? ( + {view === "prs" ? (
{t("stats.openPrs")}
{countText(filteredPullRequests.length, prsLoaded)}
{t("stats.matchingFilters")}
@@ -851,7 +903,7 @@ export function App() {
) : null} - {tab === "repos" ? ( + {view === "repos" ? (
{t("stats.repositories")}
{countText(filteredRepos.length, reposLoaded)}
{t("stats.matchingFilters")}
@@ -892,7 +944,7 @@ export function App() {
) : null} - {tab === "insights" ? ( + {view === "insights" ? (
{t("stats.averageHealth")}
{countText(averageHealth, insightsLoaded)}
{t("stats.acrossTrackedRepos")}
@@ -904,7 +956,7 @@ export function App() {
) : null} - {tab === "alerts" ? ( + {view === "alerts" ? (
{t("alerts.totalAlerts")}
{countText(totalSecurityAlerts, insightsLoaded)}
{t("alerts.affectedRepos", { count: countText(securityRepoCount, insightsLoaded) })}
@@ -922,7 +974,7 @@ export function App() {
) : null} - {tab === "ci" ? ( + {view === "ci" ? ( (() => { const totalRuns = ciHealth.reduce((sum, entry) => sum + entry.totalRuns, 0); const totalFailures = ciHealth.reduce((sum, entry) => sum + entry.failureCount, 0); @@ -944,7 +996,7 @@ export function App() { })() ) : null} - {tab === "digests" ? ( + {view === "digests" ? (
{digestPeriod === "day" ? t("stats.digestDays") : digestPeriod === "week" ? t("stats.digestWeeks") : t("stats.digestMonths")}
{countText(dailyDigests.length, digestsLoaded)}
{digestPeriod === "day" ? t("stats.daysWithSavedSnapshots") : t("stats.periodsAggregated")}
@@ -957,7 +1009,9 @@ export function App() {
) : null} - {tab === "kanban" && projectsEnabled ? { setBoardCount(count); setBoardLoaded(true); }} /> : null} + {view === "goals" ? : null} + + {view === "kanban" && projectsEnabled ? { setBoardCount(count); setBoardLoaded(true); }} /> : null}
{ + return readJson("/api/goals", withSignal(signal)); +} + +export function createGoal(payload: { + repository: string; + metric: GoalMetric; + targetValue: number; + currentValue?: number; + deadline: string; +}): Promise<{ ok: true; goal: RepositoryGoal }> { + return readJson("/api/goals", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export function deleteGoal(id: string): Promise<{ ok: true }> { + return readJson(`/api/goals/${encodeURIComponent(id)}`, { method: "DELETE" }); +} + +export function generateGoalAdvice(id: string): Promise<{ ok: true; suggestions: GoalSuggestion[]; generatedAt: string; aiEnabled: boolean }> { + return readJson(`/api/goals/${encodeURIComponent(id)}/advice`, { method: "POST" }); +} + +export function fetchGoalProposals(goalId: string, suggestionIndex: number, refresh = false): Promise { + const query = refresh ? "?refresh=1" : ""; + return readJson(`/api/goals/${encodeURIComponent(goalId)}/suggestions/${suggestionIndex}/proposals${query}`, { method: "POST" }); +} + +export function fetchAiSettings(): Promise<{ ok: true; settings: AiSettingsSummary }> { + return readJson("/api/ai/settings"); +} + +export function updateAiSettings(payload: AiSettingsUpdate): Promise<{ ok: true; settings: AiSettingsSummary }> { + return readJson("/api/ai/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export function resetAiSettings(): Promise<{ ok: true; settings: AiSettingsSummary }> { + return readJson("/api/ai/settings", { method: "DELETE" }); +} + +export function testAiSettings(): Promise { + return readJson("/api/ai/settings/test", { method: "POST" }); +} + export function fetchRepos(fresh = false, signal?: AbortSignal): Promise { return readJson(`/api/repos${fresh ? "?fresh=1" : ""}`, withSignal(signal), "/api/repos"); } diff --git a/src/components/SidebarControls.tsx b/src/components/SidebarControls.tsx index 223bff5..8c4443c 100644 --- a/src/components/SidebarControls.tsx +++ b/src/components/SidebarControls.tsx @@ -6,7 +6,7 @@ import { formatNumber } from "../utils/format"; import { ChevronIcon, CloseIcon, SearchIcon } from "./common/Icons"; import { useI18n } from "../i18n/I18nProvider"; -type Tab = "inbox" | "issues" | "repos" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "prs"; +type Tab = "inbox" | "issues" | "repos" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "prs" | "goals"; export interface InboxSidebarState { mailbox: InboxMailbox; diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 8ad981d..8b30aa4 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -23,6 +23,8 @@ interface TopBarProps { onRefresh: () => void; onOpenFilters: () => void; onOpenPalette: () => void; + onOpenPreferencesPage: () => void; + preferencesPageActive?: boolean; onLogout: () => void; canLogout?: boolean; } @@ -43,6 +45,8 @@ export function TopBar({ onRefresh, onOpenFilters, onOpenPalette, + onOpenPreferencesPage, + preferencesPageActive = false, onLogout, canLogout = true, }: TopBarProps) { @@ -100,7 +104,7 @@ export function TopBar({
+ ) : null} diff --git a/src/components/common/Icons.tsx b/src/components/common/Icons.tsx index 32fc435..1ab825b 100644 --- a/src/components/common/Icons.tsx +++ b/src/components/common/Icons.tsx @@ -46,6 +46,30 @@ export function PulseIcon() { return ; } +export function PullRequestIcon() { + return ; +} + +export function InsightsIcon() { + return ; +} + +export function AlertIcon() { + return ; +} + +export function CIIcon() { + return ; +} + +export function DigestIcon() { + return ; +} + +export function GoalIcon() { + return ; +} + export function InboxIcon() { return ; } diff --git a/src/components/common/RepositoryPicker.tsx b/src/components/common/RepositoryPicker.tsx new file mode 100644 index 0000000..fbcc32b --- /dev/null +++ b/src/components/common/RepositoryPicker.tsx @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { GhRepo } from "../../types/github"; +import { formatNumber } from "../../utils/format"; +import { BookIcon } from "./Icons"; + +interface RepositoryPickerProps { + repos: GhRepo[]; + value: string; + placeholder: string; + onChange: (repository: string) => void; +} + +export function RepositoryPicker({ repos, value, placeholder, onChange }: RepositoryPickerProps) { + const rootRef = useRef(null); + const [query, setQuery] = useState(value); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => setQuery(value), [value]); + useEffect(() => { + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, []); + + const matches = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + const sorted = [...repos].sort((a, b) => b.stargazerCount - a.stargazerCount); + if (!needle || query === value) return sorted.slice(0, 12); + return sorted.filter((repo) => `${repo.nameWithOwner} ${repo.description ?? ""} ${repo.primaryLanguage?.name ?? ""}`.toLocaleLowerCase().includes(needle)).slice(0, 12); + }, [query, repos, value]); + + function select(repo: GhRepo) { + onChange(repo.nameWithOwner); + setQuery(repo.nameWithOwner); + setOpen(false); + } + + return ( +
+
+ + setOpen(true)} + onChange={(event) => { + setQuery(event.target.value); + if (event.target.value !== value) onChange(""); + setActiveIndex(0); + setOpen(true); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); setActiveIndex((index) => Math.min(index + 1, matches.length - 1)); } + if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => Math.max(index - 1, 0)); } + if (event.key === "Enter" && open && matches[activeIndex]) { event.preventDefault(); select(matches[activeIndex]); } + if (event.key === "Escape") setOpen(false); + }} + /> + +
+ {open ? ( +
+
{matches.length ? `${matches.length} repositories` : "No repositories found"}
+ {matches.map((repo, index) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/modals/GoalProposalsModal.tsx b/src/components/modals/GoalProposalsModal.tsx new file mode 100644 index 0000000..3e37a34 --- /dev/null +++ b/src/components/modals/GoalProposalsModal.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { fetchGoalProposals } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { GoalProposal, GoalSuggestion, RepositoryGoal } from "../../types/goals"; +import { formatRelativeTime } from "../../utils/format"; +import { formatXThreadForCopy } from "../../utils/goals"; +import { socialCharacterCount } from "../../utils/socialProposals"; +import { CloseIcon, GoalIcon } from "../common/Icons"; +import { Markdown } from "../common/Markdown"; + +interface GoalProposalsModalProps { + goal: RepositoryGoal; + suggestion: GoalSuggestion; + suggestionIndex: number; + onClose: () => void; + onOpenPreferences: () => void; + /** Called with the fresh proposals so the parent can keep its goal list in sync. */ + onProposals?: (proposals: GoalProposal[], generatedAt: string) => void; +} + +type LoadState = + | { kind: "loading" } + | { kind: "ready"; proposals: GoalProposal[]; generatedAt: string } + | { kind: "no-ai" } + | { kind: "error"; message: string }; + +function CopyButton({ text, label }: { text: string; label?: string }) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1600); + return () => window.clearTimeout(timer); + }, [copied]); + return ( + + ); +} + +/** + * Shows AI-drafted deliverables for one recommended action of a goal. Drafts + * are cached server-side per suggestion; "Regenerate" asks for new ones. + */ +export function GoalProposalsModal({ goal, suggestion, suggestionIndex, onClose, onOpenPreferences, onProposals }: GoalProposalsModalProps) { + const { t, language } = useI18n(); + const [state, setState] = useState({ kind: "loading" }); + const [refreshing, setRefreshing] = useState(false); + + async function load(refresh: boolean) { + if (refresh) setRefreshing(true); + else setState({ kind: "loading" }); + try { + const result = await fetchGoalProposals(goal.id, suggestionIndex, refresh); + setState({ kind: "ready", proposals: result.proposals, generatedAt: result.generatedAt }); + onProposals?.(result.proposals, result.generatedAt); + } catch (error) { + const message = (error as Error).message; + setState(/not configured/i.test(message) ? { kind: "no-ai" } : { kind: "error", message }); + } finally { + setRefreshing(false); + } + } + + useEffect(() => { + void load(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [goal.id, suggestionIndex]); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return createPortal( +
+
+
+
+
+ +
+
{t("goals.proposalsKind")} · {suggestion.category}
+

{suggestion.title}

+
+
+ +
+ +
+

{t("goals.proposalsIntro", { repo: goal.repository })}

+
{suggestion.action}
+ + {state.kind === "loading" ? ( +
+
+ ) : null} + + {state.kind === "no-ai" ? ( +
+

{t("goals.proposalsNoAi")}

+ +
+ ) : null} + + {state.kind === "error" ?
{state.message}
: null} + + {state.kind === "ready" ? ( + state.proposals.length ? ( +
+ {state.proposals.map((proposal, index) => ( +
+
+ {t(`goals.proposalFormat.${proposal.format}`)} +
+ {proposal.title} + {proposal.summary ? {proposal.summary} : null} +
+ +
+ {proposal.format === "x-thread" && proposal.threadPosts?.length ? ( +
+ {proposal.threadPosts.map((post, postIndex) => ( +
+ +
+
+ {goal.repository} + {postIndex + 1}/{proposal.threadPosts!.length} + +
+
{post}
+ 280 ? "over-limit" : ""}>{socialCharacterCount(post)}/280 +
+
+ ))} +
+ ) : {proposal.content}} +
+ ))} +
+ ) :

{t("goals.proposalsEmpty")}

+ ) : null} +
+ +
+ + {state.kind === "ready" && state.generatedAt ? t("goals.proposalsGeneratedAt", { time: formatRelativeTime(state.generatedAt, Date.now(), language) }) : ""} + +
+ {state.kind === "ready" || state.kind === "error" ? ( + + ) : null} + +
+
+
, + document.body, + ); +} diff --git a/src/components/modals/RepositoryDetailsModal.tsx b/src/components/modals/RepositoryDetailsModal.tsx index 705f145..ee69add 100644 --- a/src/components/modals/RepositoryDetailsModal.tsx +++ b/src/components/modals/RepositoryDetailsModal.tsx @@ -726,7 +726,7 @@ export function RepositoryDetailsModal({ repo, issues, pullRequests, issuesLoade
{repoDigest.ai.headline} - {repoDigest.ai.model} + {repoDigest.ai.provider ? `${repoDigest.ai.provider} · ${repoDigest.ai.model}` : repoDigest.ai.model}
{repoDigest.ai.briefing.map((item) =>

{item}

)} diff --git a/src/components/preferences/AiIntegrationSettings.tsx b/src/components/preferences/AiIntegrationSettings.tsx new file mode 100644 index 0000000..ac83bf2 --- /dev/null +++ b/src/components/preferences/AiIntegrationSettings.tsx @@ -0,0 +1,268 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { fetchAiSettings, resetAiSettings, testAiSettings, updateAiSettings } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { AiProviderId, AiProviderInfo, AiSettingSource, AiSettingsSummary } from "../../types/ai"; +import { ConfirmDialog } from "../common/ConfirmDialog"; + +type Notice = { kind: "ok" | "error"; text: string } | null; + +const PROVIDER_GLYPHS: Record = { + openai: "O", + anthropic: "A", + gemini: "G", + openrouter: "R", + custom: "⌁", +}; + +function SourceBadge({ source }: { source: AiSettingSource }) { + const { t } = useI18n(); + return {t(`preferences.ai.source.${source}`)}; +} + +function ProviderCard({ info, selected, active, onSelect }: { info: AiProviderInfo; selected: boolean; active: boolean; onSelect: () => void }) { + const { t } = useI18n(); + const keyTag = info.hasStoredKey ? "stored" : info.hasEnvKey ? "env" : info.requiresApiKey ? "none" : null; + return ( + + ); +} + +/** + * Editor for the server-side AI provider. Values come from the environment by + * default; anything saved here is stored in SQLite and takes precedence, and + * each field shows which layer is currently in effect. + */ +export function AiIntegrationSettings() { + const { t } = useI18n(); + const [settings, setSettings] = useState(null); + const [loadError, setLoadError] = useState(""); + const [provider, setProvider] = useState("openai"); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [busy, setBusy] = useState<"save" | "test" | "reset" | "removeKey" | null>(null); + const [notice, setNotice] = useState(null); + const [confirmReset, setConfirmReset] = useState(false); + + function applySummary(next: AiSettingsSummary) { + setSettings(next); + setProvider(next.provider.value); + setApiKey(""); + setModel(next.model.source === "database" ? next.model.value ?? "" : ""); + setBaseUrl(next.baseUrl.source === "database" ? next.baseUrl.value : ""); + } + + useEffect(() => { + let cancelled = false; + fetchAiSettings() + .then((result) => { if (!cancelled) applySummary(result.settings); }) + .catch((error: Error) => { if (!cancelled) setLoadError(error.message); }); + return () => { cancelled = true; }; + }, []); + + const info = settings?.providers.find((entry) => entry.id === provider) ?? null; + const isActiveProvider = settings?.provider.value === provider; + + async function run(kind: NonNullable, action: () => Promise) { + setBusy(kind); + setNotice(null); + try { + const result = await action(); + if (typeof result === "string") setNotice({ kind: "ok", text: result }); + else { + applySummary(result); + setNotice({ kind: "ok", text: t("preferences.ai.saved") }); + } + } catch (error) { + setNotice({ kind: "error", text: (error as Error).message }); + } finally { + setBusy(null); + } + } + + function selectProvider(next: AiProviderId) { + if (!settings) return; + setProvider(next); + setNotice(null); + setApiKey(""); + const entry = settings.providers.find((item) => item.id === next); + setModel(entry?.storedModel ?? ""); + setBaseUrl(entry?.storedBaseUrl ?? ""); + } + + function save(event: FormEvent) { + event.preventDefault(); + if (!info) return; + if (!info.defaultModel && !model.trim() && !(isActiveProvider && settings?.model.value)) { + setNotice({ kind: "error", text: t("preferences.ai.modelRequired") }); + return; + } + void run("save", async () => { + const payload = { provider, model, baseUrl, ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}) }; + return (await updateAiSettings(payload)).settings; + }); + } + + if (loadError) return
{t("preferences.ai.loadError")}: {loadError}
; + if (!settings || !info) return
{t("common.loading")}
; + + const activeInfo = settings.providers.find((entry) => entry.id === settings.provider.value); + // Placeholders show what applies when the field is left empty. + const keyPlaceholder = isActiveProvider && settings.apiKey.configured + ? `${settings.apiKey.masked} · ${t("preferences.ai.apiKeyKeep")}` + : info.hasEnvKey ? t("preferences.ai.envHint", { name: info.envKeyName }) : t("preferences.ai.apiKeyMissing"); + const modelPlaceholder = isActiveProvider && settings.model.source !== "database" && settings.model.value + ? settings.model.value + : info.defaultModel ?? t("preferences.ai.modelRequired"); + const baseUrlPlaceholder = isActiveProvider && settings.baseUrl.source !== "database" ? settings.baseUrl.value : info.defaultBaseUrl; + const keySource: AiSettingSource = isActiveProvider ? settings.apiKey.source : info.hasStoredKey ? "database" : info.hasEnvKey ? "env" : "none"; + const modelSource: AiSettingSource = isActiveProvider ? settings.model.source : info.storedModel ? "database" : info.defaultModel ? "default" : "none"; + const baseUrlSource: AiSettingSource = isActiveProvider ? settings.baseUrl.source : info.storedBaseUrl ? "database" : "default"; + const hasOverrides = settings.provider.source === "database" || settings.providers.some((entry) => entry.hasStoredKey || entry.storedModel || entry.storedBaseUrl); + const showBaseUrl = info.supportsBaseUrl || baseUrlSource !== "default" || Boolean(baseUrl); + + return ( +
+
+ {settings.enabled ? t("preferences.ai.statusReady") : t("preferences.ai.statusIncomplete")} +
+ {activeInfo?.label} + {settings.model.value ? {settings.model.value} : null} +
+
+ {t("preferences.ai.provider")} + {t("preferences.ai.apiKey")} + {t("preferences.ai.model")} +
+
+ +
+
+ {t("preferences.ai.provider")} + {t("preferences.ai.providerHint")} +
+
+ {settings.providers.map((entry) => ( + selectProvider(entry.id)} + /> + ))} +
+
+ +
+
+ + + + + {showBaseUrl ? ( + + ) : null} +
+
+ +
+ + + {notice ? {notice.text} : null} +
+ {info.hasStoredKey ? ( + + ) : null} + {hasOverrides ? ( + + ) : null} +
+ +
+ {t("preferences.ai.legendTitle")} + + + + + + {t("preferences.ai.legend")} +
+ + setConfirmReset(false)} + onConfirm={() => { setConfirmReset(false); void run("reset", async () => (await resetAiSettings()).settings); }} + /> + + ); +} diff --git a/src/components/views/DailyDigestView.tsx b/src/components/views/DailyDigestView.tsx index 35a442a..54bba21 100644 --- a/src/components/views/DailyDigestView.tsx +++ b/src/components/views/DailyDigestView.tsx @@ -93,7 +93,7 @@ export function DailyDigestView({ digests, period, onPeriodChange }: DailyDigest
{digest.ai.headline} - {digest.ai.model} + {digest.ai.provider ? `${digest.ai.provider} · ${digest.ai.model}` : digest.ai.model}
{digest.ai.briefing.map((item) =>

{item}

)} diff --git a/src/components/views/GoalsLoadingState.tsx b/src/components/views/GoalsLoadingState.tsx new file mode 100644 index 0000000..18b7a7f --- /dev/null +++ b/src/components/views/GoalsLoadingState.tsx @@ -0,0 +1,51 @@ +interface GoalsLoadingStateProps { + label: string; +} + +/** Layout-matched skeleton shown while the initial goals request is pending. */ +export function GoalsLoadingState({ label }: GoalsLoadingStateProps) { + return ( +
+ {label} + {[0, 1].map((card) => ( + + ))} +
+ ); +} diff --git a/src/components/views/GoalsView.tsx b/src/components/views/GoalsView.tsx new file mode 100644 index 0000000..d07e92e --- /dev/null +++ b/src/components/views/GoalsView.tsx @@ -0,0 +1,257 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { createGoal, deleteGoal, generateGoalAdvice } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import { Avatar } from "../common/Avatar"; +import { ConfirmDialog } from "../common/ConfirmDialog"; +import { RepositoryPicker } from "../common/RepositoryPicker"; +import { GoalIcon } from "../common/Icons"; +import { GoalProposalsModal } from "../modals/GoalProposalsModal"; +import { GOAL_METRIC_DEFINITIONS, type GoalMetric, type GoalProposal, type RepositoryGoal } from "../../types/goals"; +import type { GhRepo } from "../../types/github"; +import { calculateGoalProgress, groupGoalsByRepository } from "../../utils/goals"; +import { formatNumber } from "../../utils/format"; +import { GoalsLoadingState } from "./GoalsLoadingState"; + +interface GoalsViewProps { + goals: RepositoryGoal[]; + repos: GhRepo[]; + loading: boolean; + onChange: () => Promise | void; +} + +const metricLabels = new Map(GOAL_METRIC_DEFINITIONS.map((metric) => [metric.id, metric.label])); + +function currentRepoValue(repo: GhRepo | undefined, metric: GoalMetric): number { + if (metric === "stars") return repo?.stargazerCount ?? 0; + if (metric === "forks") return repo?.forkCount ?? 0; + return 0; +} + +export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) { + const { t } = useI18n(); + const [repository, setRepository] = useState(""); + const [metric, setMetric] = useState("stars"); + const [targetValue, setTargetValue] = useState(""); + const [deadline, setDeadline] = useState(""); + const [saving, setSaving] = useState(false); + const [advisingId, setAdvisingId] = useState(null); + const [error, setError] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const [proposalTarget, setProposalTarget] = useState<{ goalId: string; index: number } | null>(null); + // Proposals fetched while the modal is open, so reopening it shows them without a round-trip. + const [proposalCache, setProposalCache] = useState>({}); + const navigate = useNavigate(); + const reposByName = useMemo(() => new Map(repos.map((repo) => [repo.nameWithOwner, repo])), [repos]); + const groupedGoals = useMemo(() => groupGoalsByRepository(goals), [goals]); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + setSaving(true); + try { + await createGoal({ + repository, + metric, + targetValue: Number(targetValue), + currentValue: currentRepoValue(reposByName.get(repository), metric), + deadline, + }); + setTargetValue(""); + setDeadline(""); + await onChange(); + } catch (cause) { + setError((cause as Error).message); + } finally { + setSaving(false); + } + } + + async function remove(id: string) { + try { + await deleteGoal(id); + await onChange(); + } catch (cause) { + setError((cause as Error).message); + } + } + + async function advise(id: string) { + setAdvisingId(id); + setError(""); + try { + await generateGoalAdvice(id); + await onChange(); + } catch (cause) { + setError((cause as Error).message); + } finally { + setAdvisingId(null); + } + } + + return ( +
+
+
+ +
+

{t("goals.createTitle")}

+

{t("goals.createDescription")}

+
+
+
void submit(event)}> + + + + + +
+
+ + {error ?
{error}
: null} + {loading && !goals.length ? : null} + {!goals.length && !loading ?

{t("goals.emptyTitle")}

{t("goals.emptyText")}

: null} +
+ {groupedGoals.map((group) => { + const repo = reposByName.get(group.repository); + const completedCount = group.goals.filter((goal) => calculateGoalProgress(goal).completed).length; + return ( +
+
+
+ +
+ {t("goals.mission")} +

{group.repository}

+

{repo?.description || t("repo.noDescription")}

+
+
+
+ {completedCount}/{group.goals.length} + {t("goals.completedMissions")} +
+
+ +
+ {group.goals.map((goal) => { + const progress = calculateGoalProgress(goal); + return ( +
+
+ {metricLabels.get(goal.metric) ?? goal.metric} + +
+
+
+
{progress.percentage}%
+
+
+
{formatNumber(goal.currentValue)}/ {formatNumber(goal.targetValue)}
+
+ +
+
+ {progress.completed ? t("goals.completed") : t("goals.remaining", { count: formatNumber(progress.remaining) })} + {progress.overdue ? t("goals.overdue") : t("goals.daysLeft", { count: progress.daysRemaining })} +
+
+
+
+ ); + })} +
+ +
+
+
{t("goals.growthStudioEyebrow")}

{t("goals.growthStudio")}

+

{t("goals.growthStudioDescription")}

+
+
+ {group.goals.map((goal) => ( +
+
+
{metricLabels.get(goal.metric) ?? goal.metric}{t("goals.aiPlan")}
+ +
+ {!goal.aiEnabled ?

{t("goals.aiFallback")}

: null} +
+ {goal.suggestions.map((suggestion, index) => { + const hasProposals = Boolean(suggestion.proposals?.length || proposalCache[`${goal.id}:${index}`]); + return ( +
+ {suggestion.category} + {suggestion.title} + +

{suggestion.action}

+
+ ); + })} +
+
+ ))} +
+
+
+ ); + })} +
+ {t("goals.deleteMessage", { + metric: deleteTarget ? metricLabels.get(deleteTarget.metric) ?? deleteTarget.metric : "", + repo: deleteTarget?.repository ?? "", + })}

} + confirmLabel={t("common.remove")} + danger + icon={} + onCancel={() => setDeleteTarget(null)} + onConfirm={() => { + const id = deleteTarget?.id; + setDeleteTarget(null); + if (id) void remove(id); + }} + /> + {proposalTarget ? (() => { + const goal = goals.find((entry) => entry.id === proposalTarget.goalId); + const suggestion = goal?.suggestions[proposalTarget.index]; + if (!goal || !suggestion) return null; + const cached = proposalCache[`${goal.id}:${proposalTarget.index}`]; + return ( + setProposalTarget(null)} + onOpenPreferences={() => { setProposalTarget(null); navigate("/preferences#preferences-ai"); }} + onProposals={(proposals, generatedAt) => setProposalCache((prev) => ({ ...prev, [`${goal.id}:${proposalTarget.index}`]: { proposals, generatedAt } }))} + /> + ); + })() : null} +
+ ); +} diff --git a/src/components/views/PreferencesView.tsx b/src/components/views/PreferencesView.tsx new file mode 100644 index 0000000..6c57d22 --- /dev/null +++ b/src/components/views/PreferencesView.tsx @@ -0,0 +1,134 @@ +import { useI18n } from "../../i18n/I18nProvider"; +import type { Language } from "../../utils/i18n"; +import { AiIntegrationSettings } from "../preferences/AiIntegrationSettings"; + +type Theme = "dark" | "light" | "auto"; +type TextSize = "small" | "normal" | "large"; + +interface PreferencesViewProps { + theme: Theme; + textSize: TextSize; + hideArchivedNoise: boolean; + onThemeChange: (theme: Theme) => void; + onTextSizeChange: (textSize: TextSize) => void; + onHideArchivedNoiseChange: (hideArchivedNoise: boolean) => void; + onBack: () => void; +} + +const PaletteIcon = () => ( + +); + +const SparkIcon = () => ( + +); + +/** Full-page preferences, reachable at `/preferences`. */ +export function PreferencesView({ + theme, + textSize, + hideArchivedNoise, + onThemeChange, + onTextSizeChange, + onHideArchivedNoiseChange, + onBack, +}: PreferencesViewProps) { + const { language, languages, setLanguage, t } = useI18n(); + + return ( +
+
+
+

{t("preferences.title")}

+

{t("preferences.subtitle")}

+
+ +
+ +
+ + +
+
+
+ +
+

{t("preferences.appearance")}

+

{t("preferences.appearanceHint")}

+
+
+
+ +
+ {t("preferences.theme")} +
+ {(["dark", "light", "auto"] as const).map((entry) => ( + + ))} +
+
+
+ {t("preferences.textSize")} +
+ {(["small", "normal", "large"] as const).map((entry) => ( + + ))} +
+
+
+
+
+ {t("preferences.hideArchivedNoise")} + {t("preferences.hideArchivedNoiseHint")} +
+
+
+ +
+
+ +
+

{t("preferences.ai")}

+

{t("preferences.ai.hint")}

+
+
+ +
+
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 77430a5..9d9f2bc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -91,6 +91,49 @@ export const en = { "preferences.theme": "Theme", "preferences.textSize": "Text size", "preferences.hideArchivedNoise": "Hide archived repo noise (PRs/issues)", + "preferences.openPage": "All preferences", + "preferences.openPageMeta": "AI integration, appearance", + "preferences.appearance": "Appearance", + "preferences.back": "Back to dashboard", + "preferences.subtitle": "Language, theme, text size and the AI provider used for digests and goals.", + "preferences.sections": "Sections", + "preferences.appearanceHint": "How the dashboard looks on this device. These settings are stored in the browser.", + "preferences.ai.providerHint": "Pick the service that generates digests and action plans. Each provider keeps its own saved key and model.", + "preferences.ai.legendTitle": "Priority", + "preferences.ai.legend": "Database overrides environment, environment overrides defaults.", + "preferences.ai.keyTag.env": "env key", + "preferences.ai.keyTag.stored": "saved key", + "preferences.ai.keyTag.none": "no key", + "preferences.ai.active": "Active", + "preferences.ai.keyNote": "Keys saved here stay on the server and are only returned masked.", + "preferences.hideArchivedNoiseHint": "Skip issues and pull requests from archived repositories in the Inbox and lists.", + "preferences.ai.customHint": "Ollama, Mistral, Groq, LM Studio…", + "preferences.ai": "AI integration", + "preferences.ai.hint": "Used for digest narratives and Goals action plans. Values saved here override the server environment.", + "preferences.ai.provider": "Provider", + "preferences.ai.apiKey": "API key", + "preferences.ai.apiKeyOptional": "API key (optional)", + "preferences.ai.apiKeyKeep": "Leave empty to keep the current key", + "preferences.ai.apiKeyMissing": "No key configured", + "preferences.ai.model": "Model", + "preferences.ai.modelRequired": "Model name required", + "preferences.ai.baseUrl": "Base URL", + "preferences.ai.source.database": "Saved in database", + "preferences.ai.source.env": "From environment", + "preferences.ai.source.default": "Default", + "preferences.ai.source.none": "Not set", + "preferences.ai.envHint": "Environment variable: {name}", + "preferences.ai.statusReady": "Ready", + "preferences.ai.statusIncomplete": "Not configured", + "preferences.ai.save": "Save", + "preferences.ai.saved": "Saved", + "preferences.ai.removeKey": "Remove stored key", + "preferences.ai.reset": "Reset to environment", + "preferences.ai.resetConfirm": "Remove every AI setting saved in the database and use the environment configuration?", + "preferences.ai.test": "Test connection", + "preferences.ai.testing": "Testing…", + "preferences.ai.testOk": "OK · {model} · {ms} ms", + "preferences.ai.loadError": "Could not load AI settings", "textSize.small": "Small", "textSize.normal": "Normal", "textSize.large": "Large", @@ -103,6 +146,58 @@ export const en = { "tabs.ci": "CI", "tabs.digest": "Digest", "tabs.board": "Board", + "tabs.goals": "Goals", + "goals.createTitle": "Set a repository goal", + "goals.createDescription": "Track a measurable result and get an action plan based on repository activity.", + "goals.repository": "Repository", + "goals.chooseRepository": "Choose a repository", + "goals.searchRepository": "Search repositories by name, language, or description…", + "goals.metric": "Metric", + "goals.target": "Target", + "goals.deadline": "Deadline", + "goals.add": "Add goal", + "goals.emptyTitle": "No goals yet", + "goals.emptyText": "Create your first measurable repository goal above.", + "goals.deleteConfirm": "Delete this goal?", + "goals.deleteTitle": "Remove goal?", + "goals.deleteMessage": "The {metric} goal for {repo} will be permanently removed. Other goals for this repository will not be affected.", + "goals.completed": "Goal reached", + "goals.remaining": "{count} remaining", + "goals.overdue": "Overdue", + "goals.daysLeft": "{count} days left", + "goals.aiPlan": "Recommended actions", + "goals.mission": "Active growth mission", + "goals.completedMissions": "Goals reached", + "goals.growthStudioEyebrow": "AI-powered playbook", + "goals.growthStudio": "Growth studio", + "goals.growthStudioDescription": "Turn repository signals into campaigns, community moves, and complete social assets ready to ship.", + "goals.generateAdvice": "Create plan", + "goals.refreshAdvice": "Refresh plan", + "goals.proposals": "Proposals", + "goals.proposalsOpen": "Get proposals", + "goals.proposalsKind": "Recommended action", + "goals.proposalsIntro": "Ready-to-use drafts for this action, based on the README and current activity of {repo}.", + "goals.proposalsLoading": "Reading the project and drafting proposals…", + "goals.proposalsRegenerate": "Regenerate", + "goals.proposalsGeneratedAt": "Generated {time}", + "goals.proposalsNoAi": "Configure an AI provider in Preferences to get proposals.", + "goals.proposalsOpenPreferences": "Open preferences", + "goals.proposalsEmpty": "No proposals yet.", + "goals.proposalFormat.x-thread": "X thread", + "goals.proposalFormat.linkedin-post": "LinkedIn post", + "goals.proposalFormat.mastodon-post": "Mastodon post", + "goals.proposalFormat.post": "Post", + "goals.proposalFormat.issue": "Issue draft", + "goals.proposalFormat.discussion": "Discussion", + "goals.proposalFormat.email": "Email", + "goals.proposalFormat.checklist": "Checklist", + "goals.proposalFormat.message": "Message", + "goals.proposalFormat.doc": "Doc", + "goals.copyThread": "Copy thread", + "goals.copyPost": "Copy post {count}", + "common.copy": "Copy", + "common.copied": "Copied", + "goals.aiFallback": "No AI provider is configured. The plan will use built-in recommendations.", "summary.issues": "{count} issues", "summary.prs": "{count} PRs", "summary.repos": "{count} repos", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 8c66aa7..08dbeff 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -93,6 +93,49 @@ export const it: Record = { "preferences.theme": "Tema", "preferences.textSize": "Dimensione testo", "preferences.hideArchivedNoise": "Nascondi rumore repo archiviati (PR/issue)", + "preferences.openPage": "Tutte le preferenze", + "preferences.openPageMeta": "Integrazione AI, aspetto", + "preferences.appearance": "Aspetto", + "preferences.back": "Torna alla dashboard", + "preferences.subtitle": "Lingua, tema, dimensione del testo e il provider AI usato per digest e goal.", + "preferences.sections": "Sezioni", + "preferences.appearanceHint": "Come appare la dashboard su questo dispositivo. Queste impostazioni sono salvate nel browser.", + "preferences.ai.providerHint": "Scegli il servizio che genera digest e piani d'azione. Ogni provider conserva la propria chiave e il proprio modello.", + "preferences.ai.legendTitle": "Priorità", + "preferences.ai.legend": "Il database sovrascrive l'ambiente, l'ambiente sovrascrive i predefiniti.", + "preferences.ai.keyTag.env": "chiave env", + "preferences.ai.keyTag.stored": "chiave salvata", + "preferences.ai.keyTag.none": "nessuna chiave", + "preferences.ai.active": "Attivo", + "preferences.ai.keyNote": "Le chiavi salvate qui restano sul server e vengono restituite solo mascherate.", + "preferences.hideArchivedNoiseHint": "Escludi issue e pull request dei repository archiviati da Inbox ed elenchi.", + "preferences.ai.customHint": "Ollama, Mistral, Groq, LM Studio…", + "preferences.ai": "Integrazione AI", + "preferences.ai.hint": "Usata per le narrative del digest e i piani d'azione dei Goal. I valori salvati qui sovrascrivono l'ambiente del server.", + "preferences.ai.provider": "Provider", + "preferences.ai.apiKey": "API key", + "preferences.ai.apiKeyOptional": "API key (opzionale)", + "preferences.ai.apiKeyKeep": "Lascia vuoto per mantenere la chiave attuale", + "preferences.ai.apiKeyMissing": "Nessuna chiave configurata", + "preferences.ai.model": "Modello", + "preferences.ai.modelRequired": "Nome modello obbligatorio", + "preferences.ai.baseUrl": "Base URL", + "preferences.ai.source.database": "Salvato nel database", + "preferences.ai.source.env": "Da ambiente", + "preferences.ai.source.default": "Predefinito", + "preferences.ai.source.none": "Non impostato", + "preferences.ai.envHint": "Variabile d'ambiente: {name}", + "preferences.ai.statusReady": "Pronto", + "preferences.ai.statusIncomplete": "Non configurato", + "preferences.ai.save": "Salva", + "preferences.ai.saved": "Salvato", + "preferences.ai.removeKey": "Rimuovi chiave salvata", + "preferences.ai.reset": "Ripristina da ambiente", + "preferences.ai.resetConfirm": "Rimuovere tutte le impostazioni AI salvate nel database e usare la configurazione d'ambiente?", + "preferences.ai.test": "Prova connessione", + "preferences.ai.testing": "Verifica…", + "preferences.ai.testOk": "OK · {model} · {ms} ms", + "preferences.ai.loadError": "Impossibile caricare le impostazioni AI", "textSize.small": "Piccolo", "textSize.normal": "Normale", "textSize.large": "Grande", @@ -105,6 +148,58 @@ export const it: Record = { "tabs.ci": "CI", "tabs.digest": "Digest", "tabs.board": "Board", + "tabs.goals": "Obiettivi", + "goals.createTitle": "Imposta un obiettivo per la repository", + "goals.createDescription": "Monitora un risultato misurabile e ricevi un piano basato sull'attività della repository.", + "goals.repository": "Repository", + "goals.chooseRepository": "Scegli una repository", + "goals.searchRepository": "Cerca per nome, linguaggio o descrizione…", + "goals.metric": "Metrica", + "goals.target": "Obiettivo", + "goals.deadline": "Scadenza", + "goals.add": "Aggiungi obiettivo", + "goals.emptyTitle": "Nessun obiettivo", + "goals.emptyText": "Crea qui sopra il tuo primo obiettivo misurabile.", + "goals.deleteConfirm": "Eliminare questo obiettivo?", + "goals.deleteTitle": "Rimuovere il goal?", + "goals.deleteMessage": "Il goal {metric} di {repo} verrà rimosso definitivamente. Gli altri goal della repository non saranno modificati.", + "goals.completed": "Obiettivo raggiunto", + "goals.remaining": "Ne mancano {count}", + "goals.overdue": "Scaduto", + "goals.daysLeft": "{count} giorni rimasti", + "goals.aiPlan": "Interventi consigliati", + "goals.mission": "Missione growth attiva", + "goals.completedMissions": "Goal raggiunti", + "goals.growthStudioEyebrow": "Playbook potenziato dall'AI", + "goals.growthStudio": "Growth studio", + "goals.growthStudioDescription": "Trasforma i segnali della repository in campagne, iniziative community e contenuti social completi pronti da pubblicare.", + "goals.generateAdvice": "Crea piano", + "goals.refreshAdvice": "Aggiorna piano", + "goals.proposals": "Proposte", + "goals.proposalsOpen": "Ottieni proposte", + "goals.proposalsKind": "Intervento consigliato", + "goals.proposalsIntro": "Bozze pronte all'uso per questo intervento, basate sul README e sull'attività attuale di {repo}.", + "goals.proposalsLoading": "Sto leggendo il progetto e preparando le proposte…", + "goals.proposalsRegenerate": "Rigenera", + "goals.proposalsGeneratedAt": "Generate {time}", + "goals.proposalsNoAi": "Configura un provider AI nelle Preferenze per ottenere proposte.", + "goals.proposalsOpenPreferences": "Apri preferenze", + "goals.proposalsEmpty": "Nessuna proposta.", + "goals.proposalFormat.x-thread": "Thread X", + "goals.proposalFormat.linkedin-post": "Post LinkedIn", + "goals.proposalFormat.mastodon-post": "Post Mastodon", + "goals.proposalFormat.post": "Post", + "goals.proposalFormat.issue": "Bozza issue", + "goals.proposalFormat.discussion": "Discussione", + "goals.proposalFormat.email": "Email", + "goals.proposalFormat.checklist": "Checklist", + "goals.proposalFormat.message": "Messaggio", + "goals.proposalFormat.doc": "Documento", + "goals.copyThread": "Copia thread", + "goals.copyPost": "Copia post {count}", + "common.copy": "Copia", + "common.copied": "Copiato", + "goals.aiFallback": "Nessun provider AI configurato. Il piano userà suggerimenti integrati.", "summary.issues": "{count} issue", "summary.prs": "{count} PR", "summary.repos": "{count} repo", diff --git a/src/server/ai/client.ts b/src/server/ai/client.ts new file mode 100644 index 0000000..b67f13c --- /dev/null +++ b/src/server/ai/client.ts @@ -0,0 +1,212 @@ +import type { AiConnectionTest } from "../../types/ai"; +import { isAiConfigured, resolveAiConfig, type ResolvedAiConfig } from "./settings"; + +const REQUEST_TIMEOUT_MS = 60_000; + +export interface JsonSchema { + type: "object"; + additionalProperties?: boolean; + properties: Record; + required: string[]; +} + +export interface StructuredRequest { + /** System-level instructions describing the task. */ + instructions: string; + /** User content, usually the data to reason about. */ + input: string; + /** JSON schema of the expected answer; the object is returned parsed. */ + schema: JsonSchema; + schemaName: string; + maxOutputTokens: number; +} + +export interface StructuredResult { + provider: string; + model: string; + data: T; +} + +export class AiNotConfiguredError extends Error { + constructor() { + super("AI provider is not configured"); + this.name = "AiNotConfiguredError"; + } +} + +export class AiRequestError extends Error { + constructor(message: string, readonly status?: number) { + super(message); + this.name = "AiRequestError"; + } +} + +async function postJson(url: string, headers: Record, body: unknown, label: string): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new AiRequestError(`${label} request failed: ${(error as Error).message}`); + } + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new AiRequestError(`${label} request failed with HTTP ${response.status}${summarizeError(detail)}`, response.status); + } + return response.json(); +} + +function summarizeError(body: string): string { + if (!body) return ""; + try { + const parsed = JSON.parse(body) as { error?: { message?: string } | string; message?: string }; + const message = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? parsed.message; + return message ? `: ${message.slice(0, 200)}` : ""; + } catch { + return `: ${body.slice(0, 200)}`; + } +} + +/** Parses a JSON object out of a model answer, tolerating code fences and prose around it. */ +export function parseJsonAnswer(text: string): T { + const trimmed = text.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + // fall through to the lenient extraction below + } + const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed); + const candidate = fenced?.[1] ?? trimmed.slice(trimmed.indexOf("{"), trimmed.lastIndexOf("}") + 1); + try { + return JSON.parse(candidate) as T; + } catch { + throw new AiRequestError("AI answer was not valid JSON"); + } +} + +function schemaInstructions(request: StructuredRequest): string { + return `${request.instructions}\n\nAnswer with a single JSON object matching this JSON schema, without markdown or commentary:\n${JSON.stringify(request.schema)}`; +} + +async function callOpenAiChat(config: ResolvedAiConfig, request: StructuredRequest): Promise { + const headers: Record = {}; + if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; + if (config.provider.id === "openrouter") { + headers["HTTP-Referer"] = "https://github.com/debba/gitdeck"; + headers["X-Title"] = "Gitdeck"; + } + // OpenAI enforces the schema server-side. Other compatible servers vary in + // what they accept, so they get plain JSON mode plus the schema in the prompt. + const strict = config.provider.id === "openai"; + const json = await postJson(`${config.baseUrl}/chat/completions`, headers, { + model: config.model, + messages: [ + { role: "system", content: strict ? request.instructions : schemaInstructions(request) }, + { role: "user", content: request.input }, + ], + response_format: strict + ? { type: "json_schema", json_schema: { name: request.schemaName, strict: true, schema: request.schema } } + : { type: "json_object" }, + max_tokens: request.maxOutputTokens, + temperature: 0.4, + }, config.provider.label) as { choices?: Array<{ message?: { content?: string | Array<{ type?: string; text?: string }> } }> }; + const content = json.choices?.[0]?.message?.content; + const text = typeof content === "string" + ? content + : (content ?? []).map((part) => part.text ?? "").join(""); + if (!text.trim()) throw new AiRequestError(`${config.provider.label} returned an empty answer`); + return parseJsonAnswer(text); +} + +async function callAnthropic(config: ResolvedAiConfig, request: StructuredRequest): Promise { + const toolName = request.schemaName; + const json = await postJson(`${config.baseUrl}/v1/messages`, { + "x-api-key": config.apiKey ?? "", + "anthropic-version": "2023-06-01", + }, { + model: config.model, + max_tokens: request.maxOutputTokens, + system: request.instructions, + messages: [{ role: "user", content: request.input }], + tools: [{ name: toolName, description: "Record the structured answer.", input_schema: request.schema }], + tool_choice: { type: "tool", name: toolName }, + }, config.provider.label) as { content?: Array<{ type?: string; name?: string; input?: unknown; text?: string }> }; + const toolUse = (json.content ?? []).find((block) => block.type === "tool_use" && block.name === toolName); + if (toolUse?.input && typeof toolUse.input === "object") return toolUse.input as T; + const text = (json.content ?? []).map((block) => block.text ?? "").join(""); + if (!text.trim()) throw new AiRequestError(`${config.provider.label} returned an empty answer`); + return parseJsonAnswer(text); +} + +/** Gemini accepts an OpenAPI subset: strip keywords it rejects. */ +function toGeminiSchema(schema: unknown): unknown { + if (Array.isArray(schema)) return schema.map(toGeminiSchema); + if (!schema || typeof schema !== "object") return schema; + const out: Record = {}; + for (const [key, value] of Object.entries(schema as Record)) { + if (key === "additionalProperties") continue; + out[key] = toGeminiSchema(value); + } + return out; +} + +async function callGemini(config: ResolvedAiConfig, request: StructuredRequest): Promise { + const url = `${config.baseUrl}/models/${encodeURIComponent(config.model ?? "")}:generateContent`; + const json = await postJson(url, { "x-goog-api-key": config.apiKey ?? "" }, { + systemInstruction: { parts: [{ text: request.instructions }] }, + contents: [{ role: "user", parts: [{ text: request.input }] }], + generationConfig: { + responseMimeType: "application/json", + responseSchema: toGeminiSchema(request.schema), + maxOutputTokens: request.maxOutputTokens, + temperature: 0.4, + }, + }, config.provider.label) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }; + const text = (json.candidates?.[0]?.content?.parts ?? []).map((part) => part.text ?? "").join(""); + if (!text.trim()) throw new AiRequestError(`${config.provider.label} returned an empty answer`); + return parseJsonAnswer(text); +} + +/** + * Runs a structured-output request against the configured provider. Callers + * describe the task once; the provider-specific wire format is handled here. + */ +export async function generateStructured(request: StructuredRequest, config = resolveAiConfig()): Promise> { + if (!isAiConfigured(config) || !config.model) throw new AiNotConfiguredError(); + let data: T; + switch (config.provider.wire) { + case "anthropic-messages": + data = await callAnthropic(config, request); + break; + case "gemini-generate": + data = await callGemini(config, request); + break; + default: + data = await callOpenAiChat(config, request); + } + return { provider: config.provider.id, model: config.model, data }; +} + +/** Sends a minimal request to verify credentials, model name and endpoint. */ +export async function testAiConnection(): Promise { + const config = resolveAiConfig(); + const startedAt = Date.now(); + const result = await generateStructured<{ reply: string }>({ + instructions: "You are a connectivity check. Reply with the single word OK.", + input: "ping", + schema: { type: "object", additionalProperties: false, properties: { reply: { type: "string" } }, required: ["reply"] }, + schemaName: "connectivity_check", + maxOutputTokens: 200, + }, config); + return { + ok: true, + provider: result.provider as AiConnectionTest["provider"], + model: result.model, + latencyMs: Date.now() - startedAt, + reply: String(result.data.reply ?? "").slice(0, 80), + }; +} diff --git a/src/server/ai/providers.ts b/src/server/ai/providers.ts new file mode 100644 index 0000000..28382ee --- /dev/null +++ b/src/server/ai/providers.ts @@ -0,0 +1,83 @@ +import type { AiProviderId } from "../../types/ai"; + +export type AiWireFormat = "openai-chat" | "anthropic-messages" | "gemini-generate"; + +export interface AiProviderDefinition { + id: AiProviderId; + label: string; + wire: AiWireFormat; + /** Environment variables checked, in order, for this provider's API key. */ + envKeyNames: string[]; + /** Environment variables checked, in order, for this provider's model. */ + envModelNames: string[]; + defaultModel: string | null; + defaultBaseUrl: string; + requiresApiKey: boolean; + supportsBaseUrl: boolean; +} + +export const AI_PROVIDERS: Record = { + openai: { + id: "openai", + label: "OpenAI", + wire: "openai-chat", + envKeyNames: ["OPENAI_API_KEY"], + // OPENAI_DIGEST_MODEL is the pre-multi-provider name, still honoured. + envModelNames: ["OPENAI_MODEL", "OPENAI_DIGEST_MODEL"], + defaultModel: "gpt-4.1-mini", + defaultBaseUrl: "https://api.openai.com/v1", + requiresApiKey: true, + supportsBaseUrl: false, + }, + anthropic: { + id: "anthropic", + label: "Anthropic", + wire: "anthropic-messages", + envKeyNames: ["ANTHROPIC_API_KEY"], + envModelNames: ["ANTHROPIC_MODEL"], + defaultModel: "claude-sonnet-5", + defaultBaseUrl: "https://api.anthropic.com", + requiresApiKey: true, + supportsBaseUrl: false, + }, + gemini: { + id: "gemini", + label: "Google Gemini", + wire: "gemini-generate", + envKeyNames: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + envModelNames: ["GEMINI_MODEL"], + defaultModel: "gemini-2.5-flash", + defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", + requiresApiKey: true, + supportsBaseUrl: false, + }, + openrouter: { + id: "openrouter", + label: "OpenRouter", + wire: "openai-chat", + envKeyNames: ["OPENROUTER_API_KEY"], + envModelNames: ["OPENROUTER_MODEL"], + defaultModel: "openai/gpt-4.1-mini", + defaultBaseUrl: "https://openrouter.ai/api/v1", + requiresApiKey: true, + supportsBaseUrl: false, + }, + custom: { + id: "custom", + label: "OpenAI-compatible", + wire: "openai-chat", + envKeyNames: [], + envModelNames: [], + defaultModel: null, + defaultBaseUrl: "http://localhost:11434/v1", + requiresApiKey: false, + supportsBaseUrl: true, + }, +}; + +/** Order used to auto-detect the provider when `AI_PROVIDER` is not set. */ +export const AI_PROVIDER_ORDER: AiProviderId[] = ["openai", "anthropic", "gemini", "openrouter", "custom"]; + +export function isAiProviderId(value: unknown): value is AiProviderId { + return typeof value === "string" && value in AI_PROVIDERS; +} diff --git a/src/server/ai/settings.ts b/src/server/ai/settings.ts new file mode 100644 index 0000000..3127616 --- /dev/null +++ b/src/server/ai/settings.ts @@ -0,0 +1,180 @@ +import type { AiProviderId, AiProviderInfo, AiSettingSource, AiSettingsSummary, AiSettingsUpdate } from "../../types/ai"; +import { deletePreference, getPreference, setPreference } from "../preferenceStore"; +import { AI_PROVIDER_ORDER, AI_PROVIDERS, isAiProviderId, type AiProviderDefinition } from "./providers"; + +const SCOPE = "ai"; +const PROVIDER_KEY = "provider"; + +interface StoredProviderOverrides { + apiKey?: string; + model?: string; + baseUrl?: string; +} + +/** Fully resolved configuration used to talk to a provider. */ +export interface ResolvedAiConfig { + provider: AiProviderDefinition; + providerSource: AiSettingSource; + apiKey: string | null; + apiKeySource: AiSettingSource; + model: string | null; + modelSource: AiSettingSource; + baseUrl: string; + baseUrlSource: AiSettingSource; +} + +function providerKey(id: AiProviderId): string { + return `provider:${id}`; +} + +function readStored(id: AiProviderId): StoredProviderOverrides { + const stored = getPreference(SCOPE, providerKey(id), null); + return stored && typeof stored === "object" ? stored : {}; +} + +function envValue(names: string[]): string | null { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + return null; +} + +function envApiKey(provider: AiProviderDefinition, explicit: boolean): string | null { + // AI_API_KEY is a generic key that only applies to the provider selected via + // AI_PROVIDER, otherwise it would be ambiguous which service it belongs to. + return envValue(provider.envKeyNames) ?? (explicit ? envValue(["AI_API_KEY"]) : null); +} + +function resolveProvider(): { provider: AiProviderDefinition; source: AiSettingSource; explicitEnv: boolean } { + const stored = getPreference(SCOPE, PROVIDER_KEY, null); + const envProvider = process.env.AI_PROVIDER?.trim().toLowerCase(); + const explicitEnv = isAiProviderId(envProvider); + if (isAiProviderId(stored)) return { provider: AI_PROVIDERS[stored], source: "database", explicitEnv: explicitEnv && envProvider === stored }; + if (explicitEnv) return { provider: AI_PROVIDERS[envProvider], source: "env", explicitEnv: true }; + const detected = AI_PROVIDER_ORDER.find((id) => envValue(AI_PROVIDERS[id].envKeyNames)); + if (detected) return { provider: AI_PROVIDERS[detected], source: "env", explicitEnv: false }; + return { provider: AI_PROVIDERS.openai, source: "default", explicitEnv: false }; +} + +/** + * Resolves the effective AI configuration. Values saved in SQLite win over the + * environment, which in turn wins over built-in defaults; every field records + * the layer it came from so the UI can show it. + */ +export function resolveAiConfig(): ResolvedAiConfig { + const { provider, source: providerSource, explicitEnv } = resolveProvider(); + const stored = readStored(provider.id); + + const storedKey = stored.apiKey?.trim() || null; + const envKey = envApiKey(provider, explicitEnv); + const apiKey = storedKey ?? envKey; + const apiKeySource: AiSettingSource = storedKey ? "database" : envKey ? "env" : "none"; + + const storedModel = stored.model?.trim() || null; + const envModel = envValue(provider.envModelNames) ?? (explicitEnv ? envValue(["AI_MODEL"]) : null); + const model = storedModel ?? envModel ?? provider.defaultModel; + const modelSource: AiSettingSource = storedModel ? "database" : envModel ? "env" : model ? "default" : "none"; + + const storedBaseUrl = stored.baseUrl?.trim() || null; + const envBaseUrl = explicitEnv ? envValue(["AI_BASE_URL"]) : null; + const baseUrl = (storedBaseUrl ?? envBaseUrl ?? provider.defaultBaseUrl).replace(/\/+$/, ""); + const baseUrlSource: AiSettingSource = storedBaseUrl ? "database" : envBaseUrl ? "env" : "default"; + + return { provider, providerSource, apiKey, apiKeySource, model, modelSource, baseUrl, baseUrlSource }; +} + +export function isAiConfigured(config = resolveAiConfig()): boolean { + if (!config.model) return false; + return Boolean(config.apiKey) || !config.provider.requiresApiKey; +} + +export function maskSecret(value: string): string { + if (value.length <= 8) return "••••"; + return `${value.slice(0, 3)}…${value.slice(-4)}`; +} + +function describeProvider(definition: AiProviderDefinition): AiProviderInfo { + const stored = readStored(definition.id); + return { + id: definition.id, + label: definition.label, + envKeyName: definition.envKeyNames[0] ?? "AI_API_KEY", + defaultModel: definition.defaultModel, + defaultBaseUrl: definition.defaultBaseUrl, + requiresApiKey: definition.requiresApiKey, + supportsBaseUrl: definition.supportsBaseUrl, + hasEnvKey: Boolean(envValue(definition.envKeyNames)), + hasStoredKey: Boolean(stored.apiKey?.trim()), + storedModel: stored.model?.trim() || null, + storedBaseUrl: stored.baseUrl?.trim() || null, + }; +} + +export function summarizeAiSettings(): AiSettingsSummary { + const config = resolveAiConfig(); + return { + enabled: isAiConfigured(config), + provider: { value: config.provider.id, source: config.providerSource }, + apiKey: { + configured: Boolean(config.apiKey), + masked: config.apiKey ? maskSecret(config.apiKey) : null, + source: config.apiKeySource, + }, + model: { value: config.model, source: config.modelSource }, + baseUrl: { value: config.baseUrl, source: config.baseUrlSource }, + providers: AI_PROVIDER_ORDER.map((id) => describeProvider(AI_PROVIDERS[id])), + }; +} + +export class AiSettingsValidationError extends Error {} + +function validateUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new AiSettingsValidationError("invalid base URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new AiSettingsValidationError("base URL must use http or https"); + return value.replace(/\/+$/, ""); +} + +/** + * Persists overrides for one provider. Fields left `undefined` are untouched, + * empty strings remove the override so the environment value applies again. + */ +export function updateAiSettings(update: AiSettingsUpdate): AiSettingsSummary { + const providerId = update.provider ?? resolveAiConfig().provider.id; + if (!isAiProviderId(providerId)) throw new AiSettingsValidationError("unknown provider"); + if (update.provider !== undefined) setPreference(SCOPE, PROVIDER_KEY, providerId); + + const next: StoredProviderOverrides = { ...readStored(providerId) }; + if (update.apiKey !== undefined) { + const apiKey = String(update.apiKey).trim(); + if (apiKey) next.apiKey = apiKey; + else delete next.apiKey; + } + if (update.model !== undefined) { + const model = String(update.model).trim(); + if (model.length > 200) throw new AiSettingsValidationError("model name too long"); + if (model) next.model = model; + else delete next.model; + } + if (update.baseUrl !== undefined) { + const baseUrl = String(update.baseUrl).trim(); + if (baseUrl) next.baseUrl = validateUrl(baseUrl); + else delete next.baseUrl; + } + + if (Object.keys(next).length) setPreference(SCOPE, providerKey(providerId), next); + else deletePreference(SCOPE, providerKey(providerId)); + return summarizeAiSettings(); +} + +/** Removes every stored override so the environment configuration applies. */ +export function resetAiSettings(): AiSettingsSummary { + deletePreference(SCOPE, PROVIDER_KEY); + for (const id of AI_PROVIDER_ORDER) deletePreference(SCOPE, providerKey(id)); + return summarizeAiSettings(); +} diff --git a/src/server/aiDigest.ts b/src/server/aiDigest.ts new file mode 100644 index 0000000..7b52124 --- /dev/null +++ b/src/server/aiDigest.ts @@ -0,0 +1,83 @@ +import type { DailyDigestRecord } from "../utils/digests"; +import type { DailyRepoDigest } from "../types/github"; +import { generateStructured } from "./ai/client"; +import { isAiConfigured } from "./ai/settings"; + +interface AiDigestResult { + provider: string; + model: string; + headline: string; + briefing: string[]; + generatedAt: string; +} + +function buildDigestPrompt(record: DailyDigestRecord | DailyRepoDigest): string { + if ("repo" in record) { + return [ + `Date: ${record.date}`, + `Repository: ${record.repo}`, + `Stars: ${record.stars} (delta ${record.starsDelta >= 0 ? "+" : ""}${record.starsDelta})`, + `Forks: ${record.forks} (delta ${record.forksDelta >= 0 ? "+" : ""}${record.forksDelta})`, + `Open issues: ${record.issueCount} (delta ${record.issueDelta >= 0 ? "+" : ""}${record.issueDelta})`, + `Stale issues: ${record.staleIssueCount} (delta ${record.staleIssueDelta >= 0 ? "+" : ""}${record.staleIssueDelta})`, + `Security alerts: ${record.securityAlertsCount}`, + "Highlights:", + ...record.highlights, + "Momentum:", + ...(record.momentum.length ? record.momentum : ["None"]), + "Risks:", + ...(record.risks.length ? record.risks : ["None"]), + ].join("\n"); + } + + const topRepos = record.repos + .slice(0, 8) + .map((repo) => `${repo.repo}: stars ${repo.stars}, forks ${repo.forks}, open issues ${repo.issueCount}, stale ${repo.staleIssueCount}`) + .join("\n"); + + return [ + `Date: ${record.date}`, + `Tracked repositories: ${record.repoCount}`, + `Total stars: ${record.totalStars}`, + `Total forks: ${record.totalForks}`, + `Open issues: ${record.issueCount}`, + `Stale issues: ${record.staleIssueCount}`, + `Security alerts: ${record.securityAlertsCount} across ${record.securityReposCount} repos`, + "Repository snapshot:", + topRepos || "None", + ].join("\n"); +} + +export async function maybeGenerateAiDigest(record: DailyDigestRecord | DailyRepoDigest): Promise { + if (!isAiConfigured()) return null; + if (record.ai?.headline && record.ai?.briefing?.length) return record.ai as AiDigestResult; + + const result = await generateStructured<{ headline: string; briefing: string[] }>({ + instructions: "You write concise engineering daily digests. Return plain JSON with keys: headline (string), briefing (array of exactly 3 strings). Keep each string under 140 characters.", + input: buildDigestPrompt(record), + schemaName: "daily_digest", + schema: { + type: "object", + additionalProperties: false, + properties: { + headline: { type: "string" }, + briefing: { + type: "array", + items: { type: "string" }, + minItems: 3, + maxItems: 3, + }, + }, + required: ["headline", "briefing"], + }, + maxOutputTokens: 300, + }); + if (!result.data.headline || !Array.isArray(result.data.briefing) || !result.data.briefing.length) return null; + return { + provider: result.provider, + model: result.model, + headline: result.data.headline, + briefing: result.data.briefing.map(String), + generatedAt: new Date().toISOString(), + }; +} diff --git a/src/server/digests.ts b/src/server/digests.ts index 7d11dce..13c1a57 100644 --- a/src/server/digests.ts +++ b/src/server/digests.ts @@ -6,7 +6,7 @@ import { DATA_DIR, DIGESTS_PATH } from "./config"; import { getIssuesCached, getReposCached } from "./dashboardData"; import { sendJsonCacheable } from "./http"; import { fetchRepoSecuritySummary } from "./securityAlerts"; -import { maybeGenerateOpenAIDigest } from "./openaiDigest"; +import { maybeGenerateAiDigest } from "./aiDigest"; const MAX_DIGEST_DAYS = 120; @@ -82,7 +82,7 @@ export async function handleDailyDigests(req: IncomingMessage, res: ServerRespon const latest = records[records.length - 1]; if (latest && !latest.ai) { try { - latest.ai = await maybeGenerateOpenAIDigest(latest); + latest.ai = await maybeGenerateAiDigest(latest); await saveDigests(); } catch { // AI enrichment is optional and should never break digest delivery. @@ -108,7 +108,7 @@ export async function getLatestRepoDigest(repo: string): Promise 0), + current_value INTEGER NOT NULL DEFAULT 0 CHECK(current_value >= 0), + deadline TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + suggestions TEXT NOT NULL DEFAULT '[]', + suggestions_generated_at TEXT + ); + CREATE INDEX IF NOT EXISTS repository_goals_account_deadline + ON repository_goals(account_id, deadline); + `); +} + +function fromRow(row: GoalRow): Omit { + let suggestions: GoalSuggestion[] = []; + try { suggestions = JSON.parse(row.suggestions) as GoalSuggestion[]; } catch { /* ignore invalid legacy data */ } + return { + id: row.id, + accountId: row.account_id, + repository: row.repository, + metric: row.metric, + targetValue: row.target_value, + currentValue: row.current_value, + deadline: row.deadline, + createdAt: row.created_at, + updatedAt: row.updated_at, + suggestions, + suggestionsGeneratedAt: row.suggestions_generated_at, + }; +} + +export function listGoals(accountId: string): Array> { + ensureSchema(); + return all("SELECT * FROM repository_goals WHERE account_id = ? ORDER BY deadline, created_at", [accountId]).map(fromRow); +} + +export function findGoal(accountId: string, id: string): Omit | null { + ensureSchema(); + const row = get("SELECT * FROM repository_goals WHERE account_id = ? AND id = ?", [accountId, id]); + return row ? fromRow(row) : null; +} + +export function createGoal(input: { + accountId: string; + repository: string; + metric: GoalMetric; + targetValue: number; + currentValue?: number; + deadline: string; +}): Omit { + ensureSchema(); + const id = randomUUID(); + const now = new Date().toISOString(); + run( + `INSERT INTO repository_goals + (id, account_id, repository, metric, target_value, current_value, deadline, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, input.accountId, input.repository, input.metric, input.targetValue, input.currentValue ?? 0, input.deadline, now, now], + ); + return findGoal(input.accountId, id)!; +} + +export function updateGoalCurrentValue(accountId: string, id: string, currentValue: number): void { + ensureSchema(); + run("UPDATE repository_goals SET current_value = ?, updated_at = ? WHERE account_id = ? AND id = ?", [currentValue, new Date().toISOString(), accountId, id]); +} + +export function saveGoalSuggestions(accountId: string, id: string, suggestions: GoalSuggestion[]): void { + ensureSchema(); + const now = new Date().toISOString(); + run("UPDATE repository_goals SET suggestions = ?, suggestions_generated_at = ?, updated_at = ? WHERE account_id = ? AND id = ?", [JSON.stringify(suggestions), now, now, accountId, id]); +} + +/** Attaches generated proposals to one suggestion; other suggestions are left untouched. */ +export function saveGoalProposals(accountId: string, id: string, index: number, proposals: GoalProposal[], proposalsVersion: number): GoalSuggestion | null { + const goal = findGoal(accountId, id); + const suggestion = goal?.suggestions[index]; + if (!goal || !suggestion) return null; + const now = new Date().toISOString(); + const updated: GoalSuggestion = { ...suggestion, proposals, proposalsGeneratedAt: now, proposalsVersion }; + const suggestions = goal.suggestions.map((entry, position) => (position === index ? updated : entry)); + run("UPDATE repository_goals SET suggestions = ?, updated_at = ? WHERE account_id = ? AND id = ?", [JSON.stringify(suggestions), now, accountId, id]); + return updated; +} + +export function deleteGoal(accountId: string, id: string): boolean { + ensureSchema(); + return run("DELETE FROM repository_goals WHERE account_id = ? AND id = ?", [accountId, id]).changes > 0; +} diff --git a/src/server/goals.ts b/src/server/goals.ts new file mode 100644 index 0000000..d599f59 --- /dev/null +++ b/src/server/goals.ts @@ -0,0 +1,257 @@ +import type { GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import { calculateGoalProgress } from "../utils/goals"; +import { hasCompleteSocialSet, normalizeSocialProposals, SOCIAL_PROPOSAL_FORMATS } from "../utils/socialProposals"; +import { AiNotConfiguredError, AiRequestError, generateStructured } from "./ai/client"; +import { isAiConfigured } from "./ai/settings"; +import { getIssuesCached, getPullRequestsCached, getReposCached } from "./dashboardData"; +import { ghApiJson, restApi, restApiPaginate } from "./githubClient"; +import { updateGoalCurrentValue } from "./goalStore"; + +interface MetricResolver { + resolve(repository: string): Promise; +} + +/** Add a metric here to make it automatically refreshable by the Goals API. */ +const METRIC_RESOLVERS: Record = { + stars: { + async resolve(repository) { + const result = await getReposCached(false); + return result.ok ? result.repos.find((repo) => repo.nameWithOwner === repository)?.stargazerCount ?? null : null; + }, + }, + forks: { + async resolve(repository) { + const result = await getReposCached(false); + return result.ok ? result.repos.find((repo) => repo.nameWithOwner === repository)?.forkCount ?? null : null; + }, + }, + closed_prs: { + async resolve(repository) { + const query = encodeURIComponent(`repo:${repository} is:pr is:closed`); + const result = await ghApiJson(`/search/issues?q=${query}&per_page=1`); + return result.ok ? Number((result.data as { total_count?: number }).total_count ?? 0) : null; + }, + }, + downloads: { + async resolve(repository) { + const result = await restApiPaginate(`/repos/${repository}/releases?per_page=100`); + if (!result.ok) return null; + return (result.data as Array<{ assets?: Array<{ download_count?: number }> }>).reduce( + (total, release) => total + (release.assets ?? []).reduce((sum, asset) => sum + (asset.download_count ?? 0), 0), + 0, + ); + }, + }, +}; + +export async function refreshGoal(goal: Omit): Promise> { + try { + const currentValue = await METRIC_RESOLVERS[goal.metric].resolve(goal.repository); + if (currentValue === null || currentValue === goal.currentValue) return goal; + updateGoalCurrentValue(goal.accountId, goal.id, currentValue); + return { ...goal, currentValue, updatedAt: new Date().toISOString() }; + } catch { + return goal; + } +} + +function fallbackSuggestions(goal: Omit): GoalSuggestion[] { + const progress = calculateGoalProgress(goal); + return [ + { + category: "product", + title: "Turn demand into a visible roadmap", + action: "Review the most discussed open issues, label the top three requests and publish which one will ship next.", + }, + { + category: "community", + title: "Reduce contribution friction", + action: "Triage unanswered issues and small PRs, add good-first-issue labels, and document one concrete contribution path.", + }, + { + category: "marketing", + title: "Publish a complete X launch thread", + action: `Tell the story of ${goal.repository} in a 5–7 post X thread: open with a concrete hook, show what the project solves, highlight recent work, share the ${progress.percentage}% goal progress, and close with one clear call to action.`, + }, + ]; +} + +export async function generateGoalSuggestions(goal: Omit): Promise { + if (!isAiConfigured()) return fallbackSuggestions(goal); + const [issuesResult, prsResult, reposResult] = await Promise.all([ + getIssuesCached(false), + getPullRequestsCached(false), + getReposCached(false), + ]); + const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const repo = reposResult.ok ? reposResult.repos.find((item) => item.nameWithOwner === goal.repository) : null; + const progress = calculateGoalProgress(goal); + const staleIssues = issues.filter((item) => Date.now() - new Date(item.updatedAt).getTime() > 30 * 86_400_000).length; + + const result = await generateStructured<{ suggestions: GoalSuggestion[] }>({ + instructions: "Act as an open-source growth and social strategist. Give specific, ethical actions grounded in the supplied activity. Include at least one substantial social campaign idea designed as a complete 5–7 post X thread, not a generic one-line post. Give it a strong hook, a useful narrative arc, concrete project details, and one clear call to action. Return JSON only.", + input: JSON.stringify({ + repository: goal.repository, + description: repo?.description, + metric: goal.metric, + current: goal.currentValue, + target: goal.targetValue, + deadline: goal.deadline, + percentage: progress.percentage, + openIssues: issues.length, + staleIssues, + openPullRequests: prs.length, + recentIssueTitles: issues.slice(0, 8).map((item) => item.title), + recentPullRequestTitles: prs.slice(0, 5).map((item) => item.title), + }), + schemaName: "goal_actions", + schema: { + type: "object", + additionalProperties: false, + properties: { + suggestions: { + type: "array", + minItems: 3, + maxItems: 5, + items: { + type: "object", + additionalProperties: false, + properties: { + category: { type: "string", enum: ["product", "community", "engineering", "marketing"] }, + title: { type: "string" }, + action: { type: "string" }, + }, + required: ["category", "title", "action"], + }, + }, + }, + required: ["suggestions"], + }, + maxOutputTokens: 900, + }); + const suggestions = Array.isArray(result.data.suggestions) ? result.data.suggestions : []; + return suggestions.length ? suggestions : fallbackSuggestions(goal); +} + +export const SOCIAL_PROPOSALS_VERSION = 2; +const README_EXCERPT_CHARS = 7000; + +interface ReleaseSignal { + name?: string | null; + tag_name?: string; + html_url?: string; + published_at?: string | null; + body?: string | null; +} + +async function fetchReleaseSignals(repository: string): Promise { + try { + const result = await restApi(`/repos/${repository}/releases?per_page=3`); + return result.ok && Array.isArray(result.data) ? result.data.slice(0, 3) : []; + } catch { + return []; + } +} + +async function fetchReadmeExcerpt(repository: string): Promise { + try { + const result = await restApi<{ content?: string; encoding?: string }>(`/repos/${repository}/readme`); + if (!result.ok || !result.data?.content) return null; + const text = result.data.encoding === "base64" ? Buffer.from(result.data.content, "base64").toString("utf-8") : result.data.content; + return text.replace(/\r/g, "").trim().slice(0, README_EXCERPT_CHARS) || null; + } catch { + return null; + } +} + +/** + * Turns one recommended action into concrete, ready-to-use deliverables + * (posts, issue drafts, checklists…) grounded in the repository's README and + * current activity. Requires a configured AI provider. + */ +export async function generateGoalProposals(goal: Omit, suggestion: GoalSuggestion): Promise { + if (!isAiConfigured()) throw new AiNotConfiguredError(); + const [issuesResult, prsResult, reposResult, readme, releases] = await Promise.all([ + getIssuesCached(false), + getPullRequestsCached(false), + getReposCached(false), + fetchReadmeExcerpt(goal.repository), + fetchReleaseSignals(goal.repository), + ]); + const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; + const repo = reposResult.ok ? reposResult.repos.find((item) => item.nameWithOwner === goal.repository) : null; + const progress = calculateGoalProgress(goal); + + const context = { + generatedOn: new Date().toISOString().slice(0, 10), + repository: goal.repository, + repositoryUrl: repo?.url ?? null, + visibility: repo?.visibility ?? null, + description: repo?.description ?? null, + primaryLanguage: repo?.primaryLanguage?.name ?? null, + verifiedMetrics: { stars: repo?.stargazerCount ?? null, forks: repo?.forkCount ?? null }, + goal: { metric: goal.metric, current: goal.currentValue, target: goal.targetValue, deadline: goal.deadline, percentage: progress.percentage }, + recommendedAngle: { category: suggestion.category, title: suggestion.title, description: suggestion.action }, + openIssues: issues.slice(0, 10).map((item) => ({ title: item.title, url: item.url, updatedAt: item.updatedAt, labels: item.labels.map((label) => label.name) })), + openPullRequests: prs.slice(0, 6).map((item) => ({ title: item.title, url: item.url, updatedAt: item.updatedAt, isDraft: item.isDraft })), + releases: releases.map((release) => ({ + name: release.name || release.tag_name || null, + url: release.html_url ?? null, + publishedAt: release.published_at ?? null, + notesExcerpt: release.body?.replace(/\s+/g, " ").trim().slice(0, 500) || null, + })), + readmeExcerpt: readme, + }; + const instructions = [ + "You are a senior open-source social strategist. Create publishable social copy, not an operational plan.", + "Choose one clear, credible campaign angle from the recommended action and adapt it to each platform and its audience.", + "Use only facts explicitly present in the input. Never invent users, benefits, benchmarks, quotes, release recency, roadmap commitments, or issue status. Treat issue and PR titles only as themes, not proof that work shipped. If evidence is thin, write a transparent invitation to try or contribute rather than making a claim.", + "Write in the main natural language of the README (English if unclear). Keep the project's own terminology and avoid generic AI phrases, hype, clickbait, fake urgency, and engagement bait.", + "Return exactly three distinct assets: one 'x-thread', one 'linkedin-post', and one 'mastodon-post'. Each must work standalone and include the supplied repository URL when it is public and available.", + "The X thread needs 5–7 ordered posts in threadPosts, each at most 280 Unicode characters. Build a coherent arc: specific hook, problem, project approach, one or two verified details, then one relevant CTA in the final post. Use at most two hashtags across the whole thread. Set content to the same posts in order.", + "The LinkedIn post should be 700–1400 characters when the evidence supports it, use short paragraphs, speak to a professional technical audience, and use at most three hashtags. Do not imitate X-thread fragments.", + "The Mastodon post must be at most 500 characters, direct and community-oriented, with at most two relevant hashtags and no engagement bait.", + "For both standalone posts threadPosts must be empty. Give each asset a concrete title. In summary, state the intended audience and the evidence-led angle in one sentence. Output JSON only.", + ].join(" "); + const schema = { + type: "object" as const, + additionalProperties: false, + properties: { + proposals: { + type: "array", + minItems: 3, + maxItems: 3, + items: { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + format: { type: "string", enum: [...SOCIAL_PROPOSAL_FORMATS] }, + summary: { type: "string" }, + content: { type: "string" }, + threadPosts: { type: "array", minItems: 0, maxItems: 7, items: { type: "string", maxLength: 280 } }, + }, + required: ["title", "format", "summary", "content", "threadPosts"], + }, + }, + }, + required: ["proposals"], + }; + + let feedback: string | null = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = await generateStructured<{ proposals: GoalProposal[] }>({ + instructions, + input: JSON.stringify({ ...context, validationFeedback: feedback }), + schemaName: "social_goal_proposals", + schema, + maxOutputTokens: 3600, + }); + const proposals = normalizeSocialProposals(result.data.proposals); + if (proposals.length === 3 && hasCompleteSocialSet(proposals)) return proposals; + feedback = "The previous answer was not publishable. Return all three required formats exactly once; use 5–7 X posts of at most 280 characters, LinkedIn content of at most 3000 characters, and Mastodon content of at most 500 characters."; + } + throw new AiRequestError("AI returned incomplete or platform-invalid social proposals"); +} diff --git a/src/server/openaiDigest.ts b/src/server/openaiDigest.ts deleted file mode 100644 index 5073b99..0000000 --- a/src/server/openaiDigest.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { DailyDigestRecord } from "../utils/digests"; -import type { DailyRepoDigest } from "../types/github"; - -const OPENAI_API_URL = "https://api.openai.com/v1/responses"; -const OPENAI_DIGEST_MODEL = process.env.OPENAI_DIGEST_MODEL ?? "gpt-4.1-mini"; - -interface OpenAIDigestResult { - model: string; - headline: string; - briefing: string[]; - generatedAt: string; -} - -function hasOpenAIConfig(): boolean { - return Boolean(process.env.OPENAI_API_KEY); -} - -function buildDigestPrompt(record: DailyDigestRecord | DailyRepoDigest): string { - if ("repo" in record) { - return [ - `Date: ${record.date}`, - `Repository: ${record.repo}`, - `Stars: ${record.stars} (delta ${record.starsDelta >= 0 ? "+" : ""}${record.starsDelta})`, - `Forks: ${record.forks} (delta ${record.forksDelta >= 0 ? "+" : ""}${record.forksDelta})`, - `Open issues: ${record.issueCount} (delta ${record.issueDelta >= 0 ? "+" : ""}${record.issueDelta})`, - `Stale issues: ${record.staleIssueCount} (delta ${record.staleIssueDelta >= 0 ? "+" : ""}${record.staleIssueDelta})`, - `Security alerts: ${record.securityAlertsCount} across ${record.securityReposCount} repos`, - "Highlights:", - ...record.highlights, - "Momentum:", - ...(record.momentum.length ? record.momentum : ["None"]), - "Risks:", - ...(record.risks.length ? record.risks : ["None"]), - ].join("\n"); - } - - const topRepos = record.repos - .slice(0, 8) - .map((repo) => `${repo.repo}: stars ${repo.stars}, forks ${repo.forks}, open issues ${repo.issueCount}, stale ${repo.staleIssueCount}`) - .join("\n"); - - return [ - `Date: ${record.date}`, - `Tracked repositories: ${record.repoCount}`, - `Total stars: ${record.totalStars}`, - `Total forks: ${record.totalForks}`, - `Open issues: ${record.issueCount}`, - `Stale issues: ${record.staleIssueCount}`, - `Security alerts: ${record.securityAlertsCount} across ${record.securityReposCount} repos`, - "Repository snapshot:", - topRepos || "None", - ].join("\n"); -} - -function extractText(response: { output?: Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }> }): string { - return (response.output || []) - .flatMap((item) => item.type === "message" ? (item.content || []) : []) - .filter((item) => item.type === "output_text" && item.text) - .map((item) => item.text) - .join("\n") - .trim(); -} - -export async function maybeGenerateOpenAIDigest(record: DailyDigestRecord | DailyRepoDigest): Promise { - if (!hasOpenAIConfig()) return null; - if (record.ai?.headline && record.ai?.briefing?.length) return record.ai; - - const response = await fetch(OPENAI_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`, - }, - body: JSON.stringify({ - model: OPENAI_DIGEST_MODEL, - instructions: "You write concise engineering daily digests. Return plain JSON with keys: headline (string), briefing (array of exactly 3 strings). Keep each string under 140 characters.", - input: buildDigestPrompt(record), - text: { - format: { - type: "json_schema", - name: "daily_digest", - schema: { - type: "object", - additionalProperties: false, - properties: { - headline: { type: "string" }, - briefing: { - type: "array", - items: { type: "string" }, - minItems: 3, - maxItems: 3, - }, - }, - required: ["headline", "briefing"], - }, - }, - }, - max_output_tokens: 300, - store: false, - }), - }); - - if (!response.ok) { - throw new Error(`OpenAI digest request failed with HTTP ${response.status}`); - } - - const json = await response.json() as { output?: Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }> }; - const text = extractText(json); - if (!text) return null; - - const parsed = JSON.parse(text) as { headline: string; briefing: string[] }; - return { - model: OPENAI_DIGEST_MODEL, - headline: parsed.headline, - briefing: parsed.briefing, - generatedAt: new Date().toISOString(), - }; -} diff --git a/src/server/preferenceStore.ts b/src/server/preferenceStore.ts new file mode 100644 index 0000000..ad7a072 --- /dev/null +++ b/src/server/preferenceStore.ts @@ -0,0 +1,46 @@ +import { get, getDatabase, run } from "./sqlite"; + +interface PreferenceRow { + value: string; +} + +function ensureSchema(): void { + getDatabase().exec(` + CREATE TABLE IF NOT EXISTS preferences ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (scope, key) + ) + `); +} + +/** + * Tiny JSON preference store. New features can persist any serialisable value + * without adding another file or schema migration. + */ +export function setPreference(scope: string, key: string, value: T): void { + ensureSchema(); + run( + `INSERT INTO preferences (scope, key, value, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(scope, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + [scope, key, JSON.stringify(value), new Date().toISOString()], + ); +} + +export function getPreference(scope: string, key: string, fallback: T): T { + ensureSchema(); + const row = get("SELECT value FROM preferences WHERE scope = ? AND key = ?", [scope, key]); + if (!row) return fallback; + try { + return JSON.parse(row.value) as T; + } catch { + return fallback; + } +} + +export function deletePreference(scope: string, key: string): void { + ensureSchema(); + run("DELETE FROM preferences WHERE scope = ? AND key = ?", [scope, key]); +} diff --git a/src/server/routes/ai.ts b/src/server/routes/ai.ts new file mode 100644 index 0000000..40774cd --- /dev/null +++ b/src/server/routes/ai.ts @@ -0,0 +1,58 @@ +import { getActive as getActiveAccount } from "../accountStore"; +import { AiNotConfiguredError, AiRequestError, testAiConnection } from "../ai/client"; +import { isAiProviderId } from "../ai/providers"; +import { AiSettingsValidationError, resetAiSettings, summarizeAiSettings, updateAiSettings } from "../ai/settings"; +import { parseJsonBody, sendJson } from "../http"; +import type { AppRouter, RouteContext } from "../router"; +import type { AiSettingsUpdate } from "../../types/ai"; + +async function requireAccount(ctx: RouteContext): Promise { + const account = await getActiveAccount(); + if (!account) sendJson(ctx.res, 401, { ok: false, needsAuth: true, error: "authentication required" }); + return Boolean(account); +} + +async function read(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + sendJson(ctx.res, 200, { ok: true, settings: summarizeAiSettings() }); +} + +async function update(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + const body = await parseJsonBody>>(ctx.req, ctx.res); + if (!body) return; + if (body.provider !== undefined && !isAiProviderId(body.provider)) return sendJson(ctx.res, 400, { ok: false, error: "unknown provider" }); + for (const field of ["apiKey", "model", "baseUrl"] as const) { + if (body[field] !== undefined && typeof body[field] !== "string") return sendJson(ctx.res, 400, { ok: false, error: `${field} must be a string` }); + } + try { + const settings = updateAiSettings(body as AiSettingsUpdate); + sendJson(ctx.res, 200, { ok: true, settings }); + } catch (error) { + if (error instanceof AiSettingsValidationError) return sendJson(ctx.res, 400, { ok: false, error: error.message }); + throw error; + } +} + +async function reset(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + sendJson(ctx.res, 200, { ok: true, settings: resetAiSettings() }); +} + +async function test(ctx: RouteContext): Promise { + if (!(await requireAccount(ctx))) return; + try { + sendJson(ctx.res, 200, await testAiConnection()); + } catch (error) { + if (error instanceof AiNotConfiguredError) return sendJson(ctx.res, 409, { ok: false, error: error.message }); + const status = error instanceof AiRequestError ? 502 : 500; + sendJson(ctx.res, status, { ok: false, error: (error as Error).message }); + } +} + +export function registerAiRoutes(router: AppRouter): void { + router.get("/api/ai/settings", read); + router.on("PUT", "/api/ai/settings", update); + router.delete("/api/ai/settings", reset); + router.post("/api/ai/settings/test", test); +} diff --git a/src/server/routes/goals.ts b/src/server/routes/goals.ts new file mode 100644 index 0000000..77fa9fd --- /dev/null +++ b/src/server/routes/goals.ts @@ -0,0 +1,100 @@ +import { getActive as getActiveAccount } from "../accountStore"; +import { createGoal, deleteGoal, findGoal, listGoals, saveGoalProposals, saveGoalSuggestions } from "../goalStore"; +import { isAiConfigured } from "../ai/settings"; +import { AiNotConfiguredError, AiRequestError } from "../ai/client"; +import { generateGoalProposals, generateGoalSuggestions, refreshGoal, SOCIAL_PROPOSALS_VERSION } from "../goals"; +import { parseJsonBody, sendJson } from "../http"; +import type { AppRouter, RouteContext } from "../router"; +import { GOAL_METRICS, type GoalMetric } from "../../types/goals"; +import { parseRepositoryName } from "../../utils/repository"; + +async function requireAccount(ctx: RouteContext) { + const account = await getActiveAccount(); + if (!account) sendJson(ctx.res, 401, { ok: false, needsAuth: true, error: "authentication required" }); + return account; +} + +async function list(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const goals = await Promise.all(listGoals(account.id).map(refreshGoal)); + sendJson(ctx.res, 200, { ok: true, goals: goals.map((goal) => ({ ...goal, aiEnabled: isAiConfigured() })) }); +} + +async function create(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const body = await parseJsonBody<{ repository?: string; metric?: string; targetValue?: number; currentValue?: number; deadline?: string }>(ctx.req, ctx.res); + if (!body) return; + const repository = body.repository?.trim() ?? ""; + const metric = body.metric as GoalMetric; + const targetValue = Number(body.targetValue); + const deadline = body.deadline ?? ""; + if (!parseRepositoryName(repository)) return sendJson(ctx.res, 400, { ok: false, error: "invalid repository" }); + if (!GOAL_METRICS.includes(metric)) return sendJson(ctx.res, 400, { ok: false, error: "invalid metric" }); + if (!Number.isSafeInteger(targetValue) || targetValue <= 0) return sendJson(ctx.res, 400, { ok: false, error: "target must be a positive integer" }); + if (!/^\d{4}-\d{2}-\d{2}$/.test(deadline) || Number.isNaN(Date.parse(deadline))) return sendJson(ctx.res, 400, { ok: false, error: "invalid deadline" }); + const initial = Number.isSafeInteger(body.currentValue) && Number(body.currentValue) >= 0 ? Number(body.currentValue) : 0; + let goal = await refreshGoal(createGoal({ accountId: account.id, repository, metric, targetValue, currentValue: initial, deadline })); + try { + const suggestions = await generateGoalSuggestions(goal); + saveGoalSuggestions(account.id, goal.id, suggestions); + goal = { ...goal, suggestions, suggestionsGeneratedAt: new Date().toISOString() }; + } catch { + // Goal creation must still succeed when the optional AI provider is unavailable. + } + sendJson(ctx.res, 201, { ok: true, goal: { ...goal, aiEnabled: isAiConfigured() } }); +} + +async function remove(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const id = ctx.params.id ?? ""; + if (!deleteGoal(account.id, id)) return sendJson(ctx.res, 404, { ok: false, error: "goal not found" }); + sendJson(ctx.res, 200, { ok: true }); +} + +async function advise(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const goal = findGoal(account.id, ctx.params.id ?? ""); + if (!goal) return sendJson(ctx.res, 404, { ok: false, error: "goal not found" }); + try { + const suggestions = await generateGoalSuggestions(await refreshGoal(goal)); + saveGoalSuggestions(account.id, goal.id, suggestions); + sendJson(ctx.res, 200, { ok: true, suggestions, generatedAt: new Date().toISOString(), aiEnabled: isAiConfigured() }); + } catch (error) { + sendJson(ctx.res, 502, { ok: false, error: (error as Error).message }); + } +} + +async function proposals(ctx: RouteContext): Promise { + const account = await requireAccount(ctx); + if (!account) return; + const goal = findGoal(account.id, ctx.params.id ?? ""); + if (!goal) return sendJson(ctx.res, 404, { ok: false, error: "goal not found" }); + const index = Number(ctx.params.index); + const suggestion = Number.isInteger(index) ? goal.suggestions[index] : undefined; + if (!suggestion) return sendJson(ctx.res, 404, { ok: false, error: "suggestion not found" }); + const refresh = ctx.url.searchParams.get("refresh") === "1"; + if (!refresh && suggestion.proposals?.length && suggestion.proposalsVersion === SOCIAL_PROPOSALS_VERSION) { + return sendJson(ctx.res, 200, { ok: true, proposals: suggestion.proposals, generatedAt: suggestion.proposalsGeneratedAt, cached: true }); + } + try { + const generated = await generateGoalProposals(goal, suggestion); + if (!generated.length) return sendJson(ctx.res, 502, { ok: false, error: "AI returned no proposals" }); + const saved = saveGoalProposals(account.id, goal.id, index, generated, SOCIAL_PROPOSALS_VERSION); + sendJson(ctx.res, 200, { ok: true, proposals: generated, generatedAt: saved?.proposalsGeneratedAt ?? new Date().toISOString(), cached: false }); + } catch (error) { + if (error instanceof AiNotConfiguredError) return sendJson(ctx.res, 409, { ok: false, error: error.message, aiEnabled: false }); + sendJson(ctx.res, error instanceof AiRequestError ? 502 : 500, { ok: false, error: (error as Error).message }); + } +} + +export function registerGoalRoutes(router: AppRouter): void { + router.post("/api/goals/:id/suggestions/:index/proposals", proposals); + router.get("/api/goals", list); + router.post("/api/goals", create); + router.delete("/api/goals/:id", remove); + router.post("/api/goals/:id/advice", advise); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index e27ebd4..56e4ec6 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,8 +1,10 @@ import type { AppRouter } from "../router"; import { registerAccountRoutes } from "./accounts"; +import { registerAiRoutes } from "./ai"; import { registerAuthRoutes } from "./auth"; import { registerDashboardRoutes } from "./dashboard"; import { registerMentionRoutes } from "./mentions"; +import { registerGoalRoutes } from "./goals"; import { registerNotificationRoutes } from "./notifications"; import { registerProjectRoutes } from "./projects"; import { registerRepositoryRoutes } from "./repository"; @@ -13,6 +15,8 @@ export function registerApiRoutes(router: AppRouter): void { registerDashboardRoutes(router); registerRepositoryRoutes(router); registerMentionRoutes(router); + registerGoalRoutes(router); registerProjectRoutes(router); registerNotificationRoutes(router); + registerAiRoutes(router); } diff --git a/src/server/spa.ts b/src/server/spa.ts index 26556ce..c1236e0 100644 --- a/src/server/spa.ts +++ b/src/server/spa.ts @@ -15,6 +15,8 @@ const APP_ROUTES = new Set([ "/ci", "/daily", "/board", + "/goals", + "/preferences", "/alert", ]); diff --git a/src/server/sqlite.ts b/src/server/sqlite.ts new file mode 100644 index 0000000..7404a48 --- /dev/null +++ b/src/server/sqlite.ts @@ -0,0 +1,38 @@ +import Database, { type Database as DatabaseType, type RunResult } from "better-sqlite3"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DATA_DIR } from "./config"; + +let database: DatabaseType | null = null; + +/** Shared SQLite helper for small, server-side persisted features. */ +export function getDatabase(path = `${DATA_DIR}/gitdeck.sqlite`): DatabaseType { + if (database) return database; + mkdirSync(dirname(path), { recursive: true }); + database = new Database(path); + database.pragma("journal_mode = WAL"); + database.pragma("foreign_keys = ON"); + return database; +} + +export function execute(sql: string): void { + getDatabase().exec(sql); +} + +export function run(sql: string, params: unknown[] = []): RunResult { + return getDatabase().prepare(sql).run(...params); +} + +export function get(sql: string, params: unknown[] = []): T | undefined { + return getDatabase().prepare(sql).get(...params) as T | undefined; +} + +export function all(sql: string, params: unknown[] = []): T[] { + return getDatabase().prepare(sql).all(...params) as T[]; +} + +/** Primarily useful for tests that need an isolated database. */ +export function closeDatabase(): void { + database?.close(); + database = null; +} diff --git a/src/styles.css b/src/styles.css index 6073ded..6454f54 100644 --- a/src/styles.css +++ b/src/styles.css @@ -9,3 +9,4 @@ @import "./styles/inbox.css"; @import "./styles/footer.css"; @import "./styles/preferences.css"; +@import "./styles/goals.css"; diff --git a/src/styles/goals.css b/src/styles/goals.css new file mode 100644 index 0000000..df4f810 --- /dev/null +++ b/src/styles/goals.css @@ -0,0 +1,198 @@ +.goals-view { display: grid; gap: 14px; } +.goal-create-card { display: grid; gap: 18px; padding: 18px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 10px; } +.goal-create-intro { display: flex; align-items: center; gap: 12px; min-width: 0; } +.goal-create-icon { display: grid; place-items: center; width: 38px; height: 38px; flex: 0 0 auto; color: var(--accent-2); background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); border-radius: 10px; } +.goal-create-icon svg { width: 18px; height: 18px; } +.goal-create-card h2 { margin: 0 0 4px; font-size: 17px; line-height: 1.2; } +.goal-create-card p { max-width: 520px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.4; } +.goal-form { display: grid; grid-template-columns: minmax(240px, 2fr) minmax(130px, .9fr) minmax(130px, .9fr) minmax(160px, 1fr) auto; gap: 10px; align-items: end; min-width: 0; } +.goal-form > .btn { min-height: 36px; padding-inline: 14px; white-space: nowrap; } +@media (min-width: 1280px) { + .goal-create-card { grid-template-columns: minmax(260px, .75fr) minmax(720px, 2.5fr); align-items: center; padding: 16px 18px; } +} +.goal-form label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.goal-form input, .goal-form select { width: 100%; min-height: 34px; padding: 6px 9px; color: var(--text); background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; } +.goal-form input:focus, .goal-form select:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); } +.repository-picker { position: relative; min-width: 0; text-transform: none; letter-spacing: normal; font-weight: 400; } +.repository-picker-input { display: flex; align-items: center; min-height: 36px; padding: 0 9px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; transition: border-color .12s, box-shadow .12s; } +.repository-picker-input.open { border-color: var(--accent); box-shadow: var(--ring); } +.repository-picker-input > svg { width: 14px; height: 14px; flex: 0 0 auto; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; } +.goal-form .repository-picker-input input { min-width: 0; min-height: 34px; padding: 6px 8px; background: transparent; border: 0; box-shadow: none; } +.goal-form .repository-picker-input input:focus { border: 0; box-shadow: none; } +.repository-picker-chevron { color: var(--muted); font-size: 15px; } +.repository-picker-menu { position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; width: max(100%, 430px); max-width: min(90vw, 560px); max-height: 390px; overflow-y: auto; padding: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 14px 40px rgba(0,0,0,.35); } +.repository-picker-summary { padding: 6px 8px 8px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +.repository-picker-menu button { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 9px; align-items: center; width: 100%; padding: 8px; color: var(--text); text-align: left; background: transparent; border: 0; border-radius: 7px; cursor: pointer; } +.repository-picker-menu button:hover, .repository-picker-menu button.active { background: var(--hover-surface); } +.repository-picker-menu button[aria-selected="true"] { box-shadow: inset 2px 0 var(--accent); } +.repository-picker-avatar { display: grid; place-items: center; width: 28px; height: 28px; color: var(--accent-2); background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 7px; } +.repository-picker-avatar svg { width: 15px; height: 15px; } +.repository-picker-copy { display: grid; min-width: 0; } +.repository-picker-copy strong { overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.repository-picker-copy small { overflow: hidden; margin-top: 2px; color: var(--muted); font-size: 10.5px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; } +.repository-picker-stats { display: grid; justify-items: end; color: var(--muted); font-size: 10.5px; white-space: nowrap; } +.repository-picker-stats small { margin-top: 2px; color: var(--muted-2); } +.goal-repository-list, .goals-loading-state { display: grid; gap: 16px; } +.goal-skeleton-card { pointer-events: none; } +.goal-skeleton { + display: block; + border-radius: 7px; + background: linear-gradient(100deg, var(--panel-3) 20%, color-mix(in srgb, var(--muted) 14%, var(--panel-2)) 38%, var(--panel-3) 56%); + background-size: 220% 100%; + animation: goalSkeletonShimmer 1.35s ease-in-out infinite; +} +.goal-skeleton-avatar { width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; } +.goal-skeleton-kicker { width: 92px; height: 7px; margin-bottom: 7px; } +.goal-skeleton-title { width: clamp(150px, 24vw, 280px); height: 17px; margin-bottom: 6px; } +.goal-skeleton-description { width: clamp(190px, 38vw, 470px); max-width: 100%; height: 9px; } +.goal-skeleton-score { width: 58px; height: 38px; } +.goal-skeleton-track { min-height: 115px; } +.goal-skeleton-metric { width: 68px; height: 9px; } +.goal-skeleton-orbit { width: 66px; height: 66px; border-radius: 50%; } +.goal-skeleton-track-copy { display: grid; gap: 9px; min-width: 0; } +.goal-skeleton-value { width: 105px; height: 19px; } +.goal-skeleton-progress { width: 100%; height: 6px; border-radius: 999px; } +.goal-skeleton-meta { width: 75%; height: 8px; } +.goal-skeleton-studio { display: grid; gap: 13px; } +.goal-skeleton-studio-title { width: 150px; height: 14px; } +.goal-skeleton-plan { min-height: 82px; } +@keyframes goalSkeletonShimmer { to { background-position-x: -220%; } } +@media (prefers-reduced-motion: reduce) { .goal-skeleton { animation: none; } } +.goal-repository-card { + --goal-tone: var(--accent); + position: relative; overflow: hidden; + background: linear-gradient(145deg, color-mix(in srgb, var(--panel) 96%, var(--accent) 4%), var(--panel)); + border: 1px solid color-mix(in srgb, var(--accent-2) 25%, var(--border-soft)); border-radius: 16px; + box-shadow: 0 18px 55px rgba(0,0,0,.18), inset 0 1px rgba(255,255,255,.035); +} +.goal-repository-card::before { content: ""; position: absolute; pointer-events: none; width: 440px; height: 220px; top: -150px; right: -80px; border-radius: 50%; background: color-mix(in srgb, var(--accent-2) 18%, transparent); filter: blur(55px); } +.goal-repository-card::after { content: ""; position: absolute; pointer-events: none; width: 280px; height: 180px; top: -130px; left: 12%; border-radius: 50%; background: color-mix(in srgb, var(--accent) 12%, transparent); filter: blur(48px); } +.goal-repository-hero { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 22px; border-bottom: 1px solid var(--border-soft); } +.goal-repository-identity { display: flex; align-items: center; gap: 13px; min-width: 0; } +.goal-repository-identity .avatar { flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border)); box-shadow: 0 0 0 4px var(--accent-faint), 0 0 28px color-mix(in srgb, var(--accent) 18%, transparent); } +.goal-repository-identity > div { min-width: 0; } +.goal-repository-kicker { display: flex; align-items: center; gap: 6px; color: var(--accent); font-size: 9px; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; } +.goal-repository-kicker i { width: 6px; height: 6px; background: var(--accent); border-radius: 50%; box-shadow: 0 0 10px var(--accent); animation: goalPulse 2s ease-in-out infinite; } +@keyframes goalPulse { 50% { opacity: .45; transform: scale(.75); } } +.goal-repository-identity h2 { overflow: hidden; margin: 3px 0 2px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; } +.goal-repository-identity p { overflow: hidden; max-width: 700px; margin: 0; color: var(--muted); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } +.goal-repository-score { display: grid; min-width: 82px; justify-items: end; } +.goal-repository-score strong { color: var(--text); font-size: 24px; line-height: 1; } +.goal-repository-score strong span { color: var(--muted-2); font-size: 14px; } +.goal-repository-score small { margin-top: 5px; color: var(--muted); font-size: 9px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; } +.goal-track-grid { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(285px, 100%), 1fr)); gap: 10px; padding: 14px; } +.goal-track { padding: 13px; background: color-mix(in srgb, var(--panel-2) 72%, transparent); border: 1px solid var(--border-soft); border-radius: 12px; transition: transform .15s, border-color .15s; } +.goal-track:hover { transform: translateY(-1px); border-color: var(--accent-border); } +.goal-track.complete { --goal-tone: var(--success); } +.goal-track.overdue { --goal-tone: var(--danger); } +.goal-track > header { display: flex; align-items: center; justify-content: space-between; min-height: 24px; } +.goal-metric { color: var(--accent-2); font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .1em; } +.goal-track-main { display: grid; grid-template-columns: 68px minmax(0, 1fr); gap: 14px; align-items: center; margin-top: 8px; } +.goal-progress-orbit { display: grid; place-items: center; width: 66px; height: 66px; padding: 5px; border-radius: 50%; box-shadow: 0 0 20px color-mix(in srgb, var(--goal-tone) 15%, transparent); } +.goal-progress-orbit > div { display: flex; align-items: baseline; justify-content: center; width: 100%; height: 100%; background: var(--panel); border-radius: 50%; } +.goal-progress-orbit strong { align-self: center; font-size: 18px; } +.goal-progress-orbit span { align-self: center; color: var(--muted); font-size: 10px; } +.goal-values { display: flex; align-items: baseline; gap: 5px; } +.goal-values strong { font-size: 21px; } +.goal-values span { color: var(--muted); font-size: 11px; } +.goal-progress { height: 6px; margin: 8px 0 7px; overflow: hidden; background: var(--panel-3); border-radius: 999px; } +.goal-progress span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--goal-tone), var(--accent-2)); box-shadow: 0 0 12px var(--goal-tone); transition: width .25s ease; } +.goal-meta { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; } +.goal-growth-studio { position: relative; z-index: 1; padding: 17px 18px 20px; border-top: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg) 28%, transparent); } +.goal-studio-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 13px; } +.goal-studio-heading span { color: var(--accent); font-size: 9px; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } +.goal-studio-heading h3 { margin: 2px 0 0; font-size: 15px; } +.goal-studio-heading p { max-width: 520px; margin: 0; color: var(--muted); font-size: 11px; text-align: right; } +.goal-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 10px; } +.goal-plan { overflow: hidden; background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--border-soft); border-radius: 12px; } +.goal-plan-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; background: linear-gradient(90deg, var(--accent-faint), transparent); border-bottom: 1px solid var(--border-soft); } +.goal-plan-head > div { display: grid; gap: 1px; } +.goal-plan-head span { color: var(--accent-2); font-size: 8.5px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; } +.goal-plan-head strong { font-size: 12px; } +.goal-plan .goal-ai-note { padding-inline: 12px; } +.goal-suggestion-list { padding: 3px 12px 12px; } +.goal-advice { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-soft); } +.goal-advice-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 10px; } +.goal-advice-title strong { font-size: 13px; } +.goal-ai-note { color: var(--muted); font-size: 11px; } +.goal-suggestion { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "cat title btn" "cat body body"; gap: 4px 8px; align-items: start; margin-top: 9px; } +.goal-suggestion > span { grid-area: cat; } +.goal-suggestion > strong { grid-area: title; } +.goal-suggestion > p { grid-area: body; } +.goal-suggestion-proposals { + grid-area: btn; display: inline-flex; align-items: center; gap: 5px; + padding: 3px 8px; border-radius: 999px; + border: 1px solid var(--border-soft); background: var(--panel-2); + color: var(--muted); font-size: 10.5px; font-weight: 700; white-space: nowrap; cursor: pointer; + transition: color .12s, border-color .12s, background .12s; +} +.goal-suggestion-proposals:hover { color: var(--accent); border-color: var(--accent-border); background: var(--accent-faint); } +.goal-suggestion-proposals.has-proposals { color: var(--accent); border-color: var(--accent-border); } +.goal-suggestion-proposals svg { flex: 0 0 auto; } +@media (max-width: 520px) { .goal-suggestion-proposals span { display: none; } } +.goal-suggestion > span { padding: 2px 6px; align-self: start; color: var(--accent-2); background: var(--panel-2); border-radius: 999px; font-size: 9px; text-transform: uppercase; } +.goal-suggestion > strong { font-size: 12px; line-height: 1.5; } +.goal-suggestion p { margin: 0; color: var(--muted); font-size: 11.5px; line-height: 1.45; white-space: pre-wrap; } +@media (max-width: 900px) { + .goal-form { grid-template-columns: 1fr 1fr; } + .goal-studio-heading { display: grid; } + .goal-studio-heading p { text-align: left; } +} +@media (max-width: 560px) { + .goal-form { grid-template-columns: 1fr; } + .goal-repository-hero { align-items: flex-start; padding: 16px; } + .goal-repository-identity p { white-space: normal; } + .goal-repository-score small { display: none; } + .goal-track-grid { padding: 10px; } + .goal-growth-studio { padding: 15px 10px; } +} + +/* Proposals modal */ +.modal.goal-proposals-modal { width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(90vh, 960px); } +.goal-proposals-category { color: var(--accent-2); text-transform: uppercase; } +.goal-proposals-body { display: grid; gap: 14px; padding: 18px; } +.goal-proposals-intro { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.5; } +.goal-proposals-action { margin: 0; padding: 10px 14px; border-left: 3px solid var(--accent); border-radius: 0 8px 8px 0; background: var(--panel-2); color: var(--text); font-size: 12.5px; line-height: 1.5; } +.goal-proposals-loading { display: flex; align-items: center; gap: 10px; padding: 26px 0; color: var(--muted); font-size: 13px; } +.goal-proposals-spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: goalSpin .8s linear infinite; } +@keyframes goalSpin { to { transform: rotate(360deg); } } +.goal-proposals-note { display: grid; justify-items: start; gap: 10px; padding: 16px; border: 1px dashed var(--border); border-radius: 10px; color: var(--muted); font-size: 12.5px; } +.goal-proposals-note p { margin: 0; } +.goal-proposal-list { display: grid; gap: 12px; transition: opacity .15s; } +.goal-proposal-list.refreshing { opacity: .5; pointer-events: none; } +.goal-proposal { border: 1px solid var(--border-soft); border-radius: 10px; background: var(--panel); overflow: hidden; } +.goal-proposal-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--border-soft); background: var(--panel-2); } +.goal-proposal-format { padding: 2px 8px; border-radius: 999px; border: 1px solid var(--accent-border); background: var(--accent-faint); color: var(--accent); font-size: 9.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; white-space: nowrap; } +.goal-proposal-format.format-issue, .goal-proposal-format.format-discussion { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border)); background: color-mix(in srgb, var(--accent-2) 12%, var(--panel-2)); } +.goal-proposal-format.format-email, .goal-proposal-format.format-message { color: #d29922; border-color: rgba(210, 153, 34, .5); background: rgba(210, 153, 34, .1); } +.goal-proposal-format.format-x-thread { color: var(--text); border-color: color-mix(in srgb, var(--text) 35%, var(--border)); background: color-mix(in srgb, var(--text) 7%, var(--panel-2)); } +.goal-proposal-copy-block { display: grid; min-width: 0; } +.goal-proposal-copy-block strong { font-size: 13px; line-height: 1.3; } +.goal-proposal-copy-block small { margin-top: 2px; color: var(--muted); font-size: 11.5px; line-height: 1.4; } +.goal-proposal-copy { min-height: 30px; padding: 4px 10px; font-size: 12px; } +.goal-proposal-copy.copied { color: #3fb950; border-color: rgba(46, 160, 67, .5); } +.goal-proposal-content { padding: 12px 16px 14px; font-size: 13px; line-height: 1.55; } +.goal-proposal-content > :first-child { margin-top: 0; } +.goal-proposal-content > :last-child { margin-bottom: 0; } +.goal-proposal-content .task-list-item { flex-wrap: wrap; } +.goal-proposal-content .task-list-item > ul, .goal-proposal-content .task-list-item > ol { flex-basis: 100%; margin-left: 22px; } +.goal-x-thread { display: grid; padding: 15px 18px 18px; } +.goal-x-post { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; } +.goal-x-post-rail { display: grid; grid-template-rows: 30px 1fr; justify-items: center; } +.goal-x-avatar { display: grid; place-items: center; width: 30px; height: 30px; color: var(--panel); background: var(--text); border-radius: 50%; font-size: 11px; font-weight: 900; } +.goal-x-post-rail i { width: 2px; min-height: 18px; margin-block: 4px; background: var(--border); } +.goal-x-post-body { min-width: 0; padding-bottom: 15px; } +.goal-x-post:last-child .goal-x-post-body { padding-bottom: 0; } +.goal-x-post-body > header { display: flex; align-items: center; gap: 7px; min-height: 30px; } +.goal-x-post-body > header > strong { overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.goal-x-post-body > header > span { color: var(--muted); font-size: 10.5px; } +.goal-x-post-body > header .goal-proposal-copy { min-height: 25px; margin-left: auto; padding: 2px 7px; font-size: 10.5px; } +.goal-x-post-content { padding: 3px 0 2px; font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } +.goal-x-post-body > small { display: block; color: var(--muted-2); font-size: 9.5px; text-align: right; } +.goal-x-post-body > small.over-limit { color: #f85149; font-weight: 700; } +@media (max-width: 560px) { + .goal-proposal-head { grid-template-columns: auto minmax(0, 1fr); } + .goal-proposal-head > .goal-proposal-copy { grid-column: 1 / -1; justify-self: end; } + .goal-x-thread { padding-inline: 12px; } + .goal-x-post-body > header .goal-proposal-copy { padding-inline: 5px; } +} diff --git a/src/styles/preferences.css b/src/styles/preferences.css index 4ea1f97..99e8f81 100644 --- a/src/styles/preferences.css +++ b/src/styles/preferences.css @@ -25,3 +25,165 @@ :root[data-text-size="large"] :where(.btn, .tab, input, select, .toolbar label, .count-chip, .data-row-title, .repo-desc, .rc-stats, .empty, .pagination, .modal-empty, .welcome-list, .digest-card, .insight-card, .ci-table) { font-size: calc(1em + 1px); } + + /* Link from the quick popover to the full page */ + .preferences-page-link { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "title icon" "meta icon"; + align-items: center; + column-gap: 10px; + width: 100%; + margin-top: 8px; + padding: 9px 10px; + border: 1px solid var(--border-soft); + border-radius: 7px; + background: var(--panel-2); + color: var(--text); + font-size: 12.5px; + font-weight: 700; + text-align: left; + cursor: pointer; + transition: border-color .12s, background .12s; + } + .preferences-page-link:hover { border-color: var(--accent-border); background: var(--accent-faint); } + .preferences-page-link > span:first-child { grid-area: title; } + .preferences-page-link-meta { grid-area: meta; color: var(--muted); font-size: 11px; font-weight: 500; } + .preferences-page-link svg { grid-area: icon; color: var(--muted); } + .preferences-page-link:hover svg { color: var(--accent); } + + /* Dedicated /preferences page */ + body.route-preferences .sidebar { display: none; } + body.route-preferences .layout { grid-template-columns: minmax(0, 1fr); } + body.route-preferences .filters-toggle { display: none !important; } + + .preferences-page { display: grid; gap: 22px; width: min(1120px, 100%); margin: 6px auto 0; } + .preferences-page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border-soft); } + .preferences-page-head h2 { margin: 0 0 6px; font-size: 24px; line-height: 1.15; letter-spacing: -0.01em; } + .preferences-page-head p { margin: 0; color: var(--muted); font-size: 13px; } + .preferences-body { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 28px; align-items: start; } + .preferences-nav { position: sticky; top: 76px; display: grid; gap: 2px; } + .preferences-nav-title { margin: 0 0 6px; padding: 0 10px; color: var(--muted); font-size: 10.5px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } + .preferences-nav a { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border-radius: 7px; color: var(--muted); font-size: 12.5px; font-weight: 600; text-decoration: none; } + .preferences-nav a svg { width: 15px; height: 15px; flex: 0 0 auto; } + .preferences-nav a:hover { color: var(--text); background: var(--hover-surface); } + .preferences-content { display: grid; gap: 18px; min-width: 0; } + + .preferences-card { padding: 20px 22px 22px; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; box-shadow: 0 1px 0 rgba(255,255,255,.035); scroll-margin-top: 80px; } + .preferences-card-head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 18px; } + .preferences-card-head h3 { margin: 0 0 3px; font-size: 16px; line-height: 1.25; } + .preferences-card-head p { margin: 0; max-width: 620px; color: var(--muted); font-size: 12.5px; line-height: 1.45; } + .preferences-card-icon { display: grid; place-items: center; width: 38px; height: 38px; flex: 0 0 auto; color: var(--text); background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 10px; } + .preferences-card-icon.accent { color: var(--accent); background: var(--accent-faint); border-color: var(--accent-border); } + .preferences-card-icon svg { width: 18px; height: 18px; } + .preferences-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 6px 22px; } + .preferences-card .preferences-field { padding: 6px 0; } + .preferences-switch { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-top: 12px; padding: 12px 14px; background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 9px; } + .preferences-switch strong { display: block; font-size: 12.5px; font-weight: 700; } + .preferences-switch small { display: block; margin-top: 2px; color: var(--muted); font-size: 11.5px; line-height: 1.4; } + + /* AI integration editor */ + .ai-settings { display: grid; gap: 18px; } + .ai-settings-loading { color: var(--muted); font-size: 12.5px; } + .ai-hero { + display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "status copy" "sources sources"; + align-items: center; gap: 10px 14px; + padding: 14px 16px; + border: 1px solid var(--border-soft); border-radius: 10px; + background: linear-gradient(135deg, color-mix(in srgb, var(--panel-2) 90%, transparent), var(--panel)); + } + .ai-hero.on { border-color: rgba(46, 160, 67, .35); background: linear-gradient(135deg, rgba(46, 160, 67, .10), var(--panel) 65%); } + .ai-hero .ai-status { grid-area: status; } + .ai-hero-copy { grid-area: copy; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; min-width: 0; font-size: 14px; } + .ai-hero-copy code { padding: 2px 7px; border-radius: 6px; background: color-mix(in srgb, var(--panel-3) 80%, transparent); color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } + .ai-hero-sources { grid-area: sources; display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--muted); font-size: 11.5px; font-weight: 600; } + .ai-hero-sources > span { display: inline-flex; align-items: center; gap: 6px; } + .ai-status { + display: inline-flex; align-items: center; gap: 7px; + padding: 5px 11px; border-radius: 999px; + border: 1px solid var(--border); background: var(--panel-2); + color: var(--muted); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; + } + .ai-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); } + .ai-status.on { color: #3fb950; border-color: rgba(46, 160, 67, .55); background: rgba(46, 160, 67, .12); } + .ai-status.on::before { background: #3fb950; box-shadow: 0 0 0 3px rgba(63, 185, 80, .2); } + + .ai-block { display: grid; gap: 10px; } + .ai-block-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } + .ai-block-title { color: var(--text); font-size: 12.5px; font-weight: 700; } + .ai-block-hint { color: var(--muted); font-size: 11.5px; } + .ai-provider-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr)); gap: 8px; } + .ai-provider-card { + display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "glyph copy" "tags tags"; + align-items: center; gap: 8px 10px; + padding: 10px 11px; + border: 1px solid var(--border-soft); border-radius: 10px; + background: var(--panel-2); color: var(--text); + text-align: left; cursor: pointer; + transition: border-color .12s, box-shadow .12s, background .12s; + } + .ai-provider-card:hover { border-color: var(--button-hover-border); background: var(--hover-surface); } + .ai-provider-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--ring); background: var(--accent-faint); } + .ai-provider-glyph { grid-area: glyph; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; color: #fff; font-size: 14px; font-weight: 800; letter-spacing: 0; } + .ai-provider-glyph-openai { background: linear-gradient(135deg, #10a37f, #0b7a5f); } + .ai-provider-glyph-anthropic { background: linear-gradient(135deg, #d4a27f, #b07a54); } + .ai-provider-glyph-gemini { background: linear-gradient(135deg, #4c8bf5, #9b6cf6); } + .ai-provider-glyph-openrouter { background: linear-gradient(135deg, #7c6cf0, #4b3fb8); } + .ai-provider-glyph-custom { background: linear-gradient(135deg, #5b6b82, #3b475a); font-size: 16px; } + .ai-provider-copy { grid-area: copy; display: grid; min-width: 0; } + .ai-provider-copy strong { overflow: hidden; font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } + .ai-provider-copy small { overflow: hidden; margin-top: 1px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + .ai-provider-tags { grid-area: tags; display: flex; flex-wrap: wrap; gap: 4px; min-height: 16px; } + .ai-provider-tags:empty { display: none; } + .ai-provider-tag { padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-soft); color: var(--muted); font-size: 9.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } + .ai-provider-tag.active { color: #3fb950; border-color: rgba(46, 160, 67, .5); } + .ai-provider-tag.key-env { color: #d29922; border-color: rgba(210, 153, 34, .5); } + .ai-provider-tag.key-stored { color: var(--accent); border-color: var(--accent-border); } + + .ai-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; } + .ai-field { display: grid; gap: 6px; min-width: 0; font-size: 12.5px; } + .ai-field-wide { grid-column: 1 / -1; } + .ai-field-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 11.5px; font-weight: 700; } + .ai-field input { + width: 100%; min-height: 36px; padding: 7px 10px; + color: var(--text); background: var(--panel-2); + border: 1px solid var(--border); border-radius: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; + transition: border-color .12s, box-shadow .12s; + } + .ai-field input::placeholder { color: var(--muted-2); } + .ai-field input:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); } + .ai-field small { color: var(--muted); font-size: 11px; line-height: 1.4; } + .ai-source { + padding: 2px 7px; border-radius: 999px; + border: 1px solid var(--border-soft); background: var(--panel-2); + color: var(--muted); font-size: 9.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; white-space: nowrap; + } + .ai-source-database { color: var(--accent); border-color: var(--accent-border); background: var(--accent-soft); } + .ai-source-env { color: #d29922; border-color: rgba(210, 153, 34, .5); background: rgba(210, 153, 34, .1); } + + .ai-settings-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + .ai-settings-actions .spacer { flex: 1; } + .ai-settings-actions .btn { min-height: 34px; padding-inline: 14px; } + .ai-link-danger { padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer; } + .ai-link-danger:hover { color: var(--danger, #f85149); background: rgba(248, 81, 73, .08); } + .ai-link-danger:disabled { opacity: .5; cursor: default; } + .ai-settings-notice { padding: 6px 10px; border-radius: 7px; font-size: 12px; font-weight: 600; border: 1px solid var(--border-soft); background: var(--panel-2); } + .ai-settings-notice.ok { color: #3fb950; border-color: rgba(46, 160, 67, .4); background: rgba(46, 160, 67, .08); } + .ai-settings-notice.error { color: #f85149; border-color: rgba(248, 81, 73, .4); background: rgba(248, 81, 73, .08); } + .ai-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 14px; border-top: 1px dashed var(--border-soft); color: var(--muted); font-size: 11.5px; } + .ai-legend-title { margin-right: 4px; font-weight: 700; } + .ai-legend-arrow { color: var(--muted-2); } + .ai-legend-text { margin-left: 6px; } + + @media (max-width: 960px) { + .preferences-body { grid-template-columns: minmax(0, 1fr); } + .preferences-nav { position: static; display: flex; flex-wrap: wrap; gap: 4px; } + .preferences-nav-title { display: none; } + } + @media (max-width: 720px) { + .preferences-page-head { flex-direction: column; align-items: flex-start; } + .preferences-card { padding: 16px; } + .ai-fields { grid-template-columns: minmax(0, 1fr); } + .ai-hero { grid-template-columns: minmax(0, 1fr); grid-template-areas: "status" "copy" "sources"; } + } diff --git a/src/types/ai.ts b/src/types/ai.ts new file mode 100644 index 0000000..7a7fd86 --- /dev/null +++ b/src/types/ai.ts @@ -0,0 +1,49 @@ +export const AI_PROVIDER_IDS = ["openai", "anthropic", "gemini", "openrouter", "custom"] as const; +export type AiProviderId = (typeof AI_PROVIDER_IDS)[number]; + +/** Where an effective AI setting value comes from. */ +export type AiSettingSource = "database" | "env" | "default" | "none"; + +export interface AiProviderInfo { + id: AiProviderId; + label: string; + /** Environment variable read for this provider's API key. */ + envKeyName: string; + defaultModel: string | null; + defaultBaseUrl: string; + requiresApiKey: boolean; + /** Whether the base URL is meaningful for the user (custom endpoints). */ + supportsBaseUrl: boolean; + hasEnvKey: boolean; + hasStoredKey: boolean; + storedModel: string | null; + storedBaseUrl: string | null; +} + +export interface AiSettingsSummary { + /** True when the active provider has everything it needs to answer requests. */ + enabled: boolean; + provider: { value: AiProviderId; source: AiSettingSource }; + apiKey: { configured: boolean; masked: string | null; source: AiSettingSource }; + model: { value: string | null; source: AiSettingSource }; + baseUrl: { value: string; source: AiSettingSource }; + providers: AiProviderInfo[]; +} + +export interface AiSettingsUpdate { + provider?: AiProviderId; + /** Omit to keep the stored key, empty string to remove the override. */ + apiKey?: string; + /** Empty string removes the override. */ + model?: string; + /** Empty string removes the override. */ + baseUrl?: string; +} + +export interface AiConnectionTest { + ok: true; + provider: AiProviderId; + model: string; + latencyMs: number; + reply: string; +} diff --git a/src/types/github.ts b/src/types/github.ts index c601e3b..6cb20e4 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -388,6 +388,7 @@ export interface DailyRepoDigest { momentum: string[]; risks: string[]; ai?: { + provider?: string; model: string; headline: string; briefing: string[]; @@ -415,6 +416,7 @@ export interface DailyDigestEntry { risks: string[]; repos: DailyRepoDigest[]; ai?: { + provider?: string; model: string; headline: string; briefing: string[]; diff --git a/src/types/goals.ts b/src/types/goals.ts new file mode 100644 index 0000000..07cefb6 --- /dev/null +++ b/src/types/goals.ts @@ -0,0 +1,61 @@ +/** Add display metadata here; the metric type and creation UI update automatically. */ +export const GOAL_METRIC_DEFINITIONS = [ + { id: "stars", label: "Stars" }, + { id: "forks", label: "Forks" }, + { id: "closed_prs", label: "Closed PRs" }, + { id: "downloads", label: "Release downloads" }, +] as const; + +export type GoalMetric = typeof GOAL_METRIC_DEFINITIONS[number]["id"]; +export const GOAL_METRICS: readonly GoalMetric[] = GOAL_METRIC_DEFINITIONS.map((metric) => metric.id); + +export const GOAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post", "post", "issue", "discussion", "email", "checklist", "message", "doc"] as const; +export type GoalProposalFormat = (typeof GOAL_PROPOSAL_FORMATS)[number]; + +/** A ready-to-use deliverable that carries out one recommended action. */ +export interface GoalProposal { + title: string; + format: GoalProposalFormat; + summary: string; + /** Markdown text the user can copy and publish or adapt. */ + content: string; + /** Complete, ordered X posts. Present when format is `x-thread`. */ + threadPosts?: string[]; +} + +export interface GoalSuggestion { + title: string; + action: string; + category: "product" | "community" | "engineering" | "marketing"; + proposals?: GoalProposal[]; + proposalsGeneratedAt?: string | null; + /** Generation strategy version, used to invalidate obsolete cached drafts. */ + proposalsVersion?: number; +} + +export interface GoalProposalsData { + ok: true; + proposals: GoalProposal[]; + generatedAt: string; + cached: boolean; +} + +export interface RepositoryGoal { + id: string; + accountId: string; + repository: string; + metric: GoalMetric; + targetValue: number; + currentValue: number; + deadline: string; + createdAt: string; + updatedAt: string; + suggestions: GoalSuggestion[]; + suggestionsGeneratedAt: string | null; + aiEnabled: boolean; +} + +export interface GoalsData { + ok: true; + goals: RepositoryGoal[]; +} diff --git a/src/utils/dataRequirements.ts b/src/utils/dataRequirements.ts index 1969329..fd5c4ef 100644 --- a/src/utils/dataRequirements.ts +++ b/src/utils/dataRequirements.ts @@ -1,4 +1,4 @@ -export type DashboardTab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests"; +export type DashboardTab = "inbox" | "repos" | "issues" | "prs" | "kanban" | "insights" | "alerts" | "ci" | "digests" | "goals"; export type DashboardResource = "repos" | "issues" | "prs"; @@ -21,6 +21,7 @@ export function dataRequirementsForTab( resources.add("prs"); break; case "repos": + case "goals": resources.add("repos"); break; case "insights": diff --git a/src/utils/digests.ts b/src/utils/digests.ts index 0a0c15e..8c2fe07 100644 --- a/src/utils/digests.ts +++ b/src/utils/digests.ts @@ -29,6 +29,7 @@ export interface DailyDigestRecord { totalForks: number; repos: DailyRepoRecord[]; ai?: { + provider?: string; model: string; headline: string; briefing: string[]; diff --git a/src/utils/goals.ts b/src/utils/goals.ts new file mode 100644 index 0000000..686c912 --- /dev/null +++ b/src/utils/goals.ts @@ -0,0 +1,49 @@ +import type { GoalProposal, RepositoryGoal } from "../types/goals"; + +export interface RepositoryGoalGroup { + repository: string; + goals: RepositoryGoal[]; +} + +/** Groups goals without changing repository or goal insertion order. */ +export function groupGoalsByRepository(goals: RepositoryGoal[]): RepositoryGoalGroup[] { + const groups = new Map(); + for (const goal of goals) { + const current = groups.get(goal.repository); + if (current) current.push(goal); + else groups.set(goal.repository, [goal]); + } + return [...groups].map(([repository, repositoryGoals]) => ({ repository, goals: repositoryGoals })); +} + +export interface GoalProgress { + percentage: number; + remaining: number; + daysRemaining: number; + completed: boolean; + overdue: boolean; +} + +/** Produces a copy-ready thread while keeping each X post visibly separated. */ +export function formatXThreadForCopy(proposal: Pick): string { + const posts = proposal.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? []; + return posts.length ? posts.join("\n\n---\n\n") : proposal.content.trim(); +} + +export function calculateGoalProgress( + goal: Pick, + now = new Date(), +): GoalProgress { + const target = Math.max(1, goal.targetValue); + const percentage = Math.min(100, Math.max(0, Math.round((goal.currentValue / target) * 100))); + const completed = goal.currentValue >= goal.targetValue; + const deadline = new Date(`${goal.deadline}T23:59:59.999Z`).getTime(); + const daysRemaining = Math.max(0, Math.ceil((deadline - now.getTime()) / 86_400_000)); + return { + percentage, + remaining: Math.max(0, goal.targetValue - goal.currentValue), + daysRemaining, + completed, + overdue: !completed && deadline < now.getTime(), + }; +} diff --git a/src/utils/socialProposals.ts b/src/utils/socialProposals.ts new file mode 100644 index 0000000..dcd19db --- /dev/null +++ b/src/utils/socialProposals.ts @@ -0,0 +1,80 @@ +import type { GoalProposal, GoalProposalFormat } from "../types/goals"; + +export const SOCIAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post"] as const satisfies readonly GoalProposalFormat[]; + +const SOCIAL_LIMITS: Partial> = { + "x-thread": 280, + "linkedin-post": 3_000, + "mastodon-post": 500, +}; + +/** Counts Unicode code points rather than UTF-16 units, which avoids double-counting most emoji. */ +export function socialCharacterCount(text: string): number { + return Array.from(text).length; +} + +function hashtagCount(text: string): number { + return [...text.matchAll(/(?:^|\s)#[\p{L}\p{N}_]+/gu)].length; +} + +export function socialProposalIssue(proposal: GoalProposal): string | null { + if (!proposal.title.trim()) return "missing title"; + if (!proposal.summary.trim()) return "missing audience and angle summary"; + if (!proposal.content.trim()) return "missing content"; + if (!SOCIAL_PROPOSAL_FORMATS.includes(proposal.format as (typeof SOCIAL_PROPOSAL_FORMATS)[number])) { + return `unsupported social format: ${proposal.format}`; + } + + if (proposal.format === "x-thread") { + const posts = proposal.threadPosts?.map((post) => post.trim()).filter(Boolean) ?? []; + if (posts.length < 5 || posts.length > 7) return "X thread must contain 5–7 posts"; + if (posts.some((post) => socialCharacterCount(post) > SOCIAL_LIMITS["x-thread"]!)) return "X post exceeds 280 characters"; + if (hashtagCount(posts.join("\n")) > 2) return "X thread contains more than 2 hashtags"; + return null; + } + + const limit = SOCIAL_LIMITS[proposal.format]; + if (limit && socialCharacterCount(proposal.content) > limit) return `${proposal.format} exceeds ${limit} characters`; + const hashtagLimit = proposal.format === "linkedin-post" ? 3 : 2; + return hashtagCount(proposal.content) > hashtagLimit ? `${proposal.format} contains too many hashtags` : null; +} + +/** + * Sanitizes model output and rejects incomplete, duplicate, or platform-invalid + * social drafts instead of showing content that cannot actually be published. + */ +export function normalizeSocialProposals(entries: unknown): GoalProposal[] { + if (!Array.isArray(entries)) return []; + const proposals: GoalProposal[] = []; + const seenFormats = new Set(); + const seenContent = new Set(); + + for (const raw of entries) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const format = String(entry.format ?? "") as GoalProposalFormat; + const posts = format === "x-thread" && Array.isArray(entry.threadPosts) + ? entry.threadPosts.map((post) => String(post).trim()).filter(Boolean).slice(0, 7) + : undefined; + const content = format === "x-thread" && posts?.length + ? posts.join("\n\n---\n\n") + : String(entry.content ?? "").trim(); + const proposal: GoalProposal = { + title: String(entry.title ?? "").trim(), + format, + summary: String(entry.summary ?? "").trim(), + content, + threadPosts: posts, + }; + const fingerprint = content.toLocaleLowerCase(); + if (seenFormats.has(format) || seenContent.has(fingerprint) || socialProposalIssue(proposal)) continue; + seenFormats.add(format); + seenContent.add(fingerprint); + proposals.push(proposal); + } + return proposals; +} + +export function hasCompleteSocialSet(proposals: GoalProposal[]): boolean { + return SOCIAL_PROPOSAL_FORMATS.every((format) => proposals.some((proposal) => proposal.format === format)); +} diff --git a/tests/server/aiClient.test.ts b/tests/server/aiClient.test.ts new file mode 100644 index 0000000..dd67ea1 --- /dev/null +++ b/tests/server/aiClient.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AiNotConfiguredError, generateStructured, parseJsonAnswer } from "../../src/server/ai/client"; +import { AI_PROVIDERS } from "../../src/server/ai/providers"; +import type { ResolvedAiConfig } from "../../src/server/ai/settings"; + +function config(id: keyof typeof AI_PROVIDERS, overrides: Partial = {}): ResolvedAiConfig { + const provider = AI_PROVIDERS[id]; + return { + provider, + providerSource: "env", + apiKey: "test-key", + apiKeySource: "env", + model: provider.defaultModel ?? "local-model", + modelSource: "default", + baseUrl: provider.defaultBaseUrl, + baseUrlSource: "default", + ...overrides, + }; +} + +const request = { + instructions: "Summarise.", + input: "data", + schemaName: "answer", + schema: { type: "object" as const, additionalProperties: false, properties: { headline: { type: "string" } }, required: ["headline"] }, + maxOutputTokens: 100, +}; + +function mockFetch(body: unknown, status = 200) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function lastCall(fetchMock: ReturnType): { url: string; headers: Record; body: Record } { + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + return { url, headers: init.headers as Record, body: JSON.parse(String(init.body)) as Record }; +} + +describe("parseJsonAnswer", () => { + it("accepts plain JSON, fenced JSON and JSON surrounded by prose", () => { + expect(parseJsonAnswer('{"a":1}')).toEqual({ a: 1 }); + expect(parseJsonAnswer('Here you go:\n```json\n{"a":2}\n```')).toEqual({ a: 2 }); + expect(parseJsonAnswer('Sure! {"a":3} hope it helps')).toEqual({ a: 3 }); + expect(() => parseJsonAnswer("nope")).toThrow(/not valid JSON/); + }); +}); + +describe("generateStructured", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("refuses to run without credentials", async () => { + await expect(generateStructured(request, config("openai", { apiKey: null, apiKeySource: "none" }))).rejects.toBeInstanceOf(AiNotConfiguredError); + }); + + it("uses strict JSON schema output with OpenAI", async () => { + const fetchMock = mockFetch({ choices: [{ message: { content: '{"headline":"hi"}' } }] }); + const result = await generateStructured<{ headline: string }>(request, config("openai")); + expect(result).toEqual({ provider: "openai", model: "gpt-4.1-mini", data: { headline: "hi" } }); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://api.openai.com/v1/chat/completions"); + expect(call.headers.Authorization).toBe("Bearer test-key"); + expect(call.body.response_format).toMatchObject({ type: "json_schema", json_schema: { name: "answer", strict: true } }); + }); + + it("falls back to JSON mode plus prompt schema for OpenRouter and adds attribution headers", async () => { + const fetchMock = mockFetch({ choices: [{ message: { content: '```json\n{"headline":"routed"}\n```' } }] }); + const result = await generateStructured<{ headline: string }>(request, config("openrouter")); + expect(result.data.headline).toBe("routed"); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://openrouter.ai/api/v1/chat/completions"); + expect(call.headers["X-Title"]).toBe("Gitdeck"); + expect(call.body.response_format).toEqual({ type: "json_object" }); + expect(String((call.body.messages as Array<{ content: string }>)[0].content)).toContain('"headline"'); + }); + + it("works against a key-less OpenAI-compatible endpoint", async () => { + const fetchMock = mockFetch({ choices: [{ message: { content: '{"headline":"local"}' } }] }); + const result = await generateStructured<{ headline: string }>(request, config("custom", { apiKey: null, apiKeySource: "none", baseUrl: "http://localhost:11434/v1", model: "llama3" })); + expect(result).toMatchObject({ provider: "custom", model: "llama3" }); + const call = lastCall(fetchMock); + expect(call.url).toBe("http://localhost:11434/v1/chat/completions"); + expect(call.headers.Authorization).toBeUndefined(); + }); + + it("forces a tool call with Anthropic and reads the tool input", async () => { + const fetchMock = mockFetch({ content: [{ type: "tool_use", name: "answer", input: { headline: "claude" } }] }); + const result = await generateStructured<{ headline: string }>(request, config("anthropic")); + expect(result.data).toEqual({ headline: "claude" }); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://api.anthropic.com/v1/messages"); + expect(call.headers["x-api-key"]).toBe("test-key"); + expect(call.body.tool_choice).toEqual({ type: "tool", name: "answer" }); + }); + + it("strips unsupported schema keywords for Gemini", async () => { + const fetchMock = mockFetch({ candidates: [{ content: { parts: [{ text: '{"headline":"gemini"}' }] } }] }); + const result = await generateStructured<{ headline: string }>(request, config("gemini")); + expect(result.data).toEqual({ headline: "gemini" }); + const call = lastCall(fetchMock); + expect(call.url).toBe("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"); + expect(call.headers["x-goog-api-key"]).toBe("test-key"); + const generation = call.body.generationConfig as { responseSchema: Record; responseMimeType: string }; + expect(generation.responseMimeType).toBe("application/json"); + expect(generation.responseSchema).not.toHaveProperty("additionalProperties"); + }); + + it("surfaces upstream error messages", async () => { + mockFetch({ error: { message: "invalid model" } }, 400); + await expect(generateStructured(request, config("openai"))).rejects.toThrow(/HTTP 400: invalid model/); + }); +}); diff --git a/tests/server/aiSettings.test.ts b/tests/server/aiSettings.test.ts new file mode 100644 index 0000000..e329378 --- /dev/null +++ b/tests/server/aiSettings.test.ts @@ -0,0 +1,112 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { rm } from "node:fs/promises"; + +const { TMP_DIR } = vi.hoisted(() => { + const { tmpdir } = require("node:os") as typeof import("node:os"); + const { resolve } = require("node:path") as typeof import("node:path"); + return { TMP_DIR: resolve(tmpdir(), `gitdeck-ai-settings-${process.pid}-${Date.now()}`) }; +}); + +vi.mock("../../src/server/config", () => ({ DATA_DIR: TMP_DIR })); + +const settings = await import("../../src/server/ai/settings"); +const { closeDatabase } = await import("../../src/server/sqlite"); + +const AI_ENV = [ + "AI_PROVIDER", "AI_API_KEY", "AI_MODEL", "AI_BASE_URL", + "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_DIGEST_MODEL", + "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "OPENROUTER_MODEL", +]; + +describe("AI settings resolution", () => { + beforeEach(() => { + for (const name of AI_ENV) delete process.env[name]; + settings.resetAiSettings(); + }); + afterEach(() => { + for (const name of AI_ENV) delete process.env[name]; + }); + afterAll(async () => { + closeDatabase(); + await rm(TMP_DIR, { recursive: true, force: true }); + }); + + it("falls back to OpenAI defaults and reports the feature as disabled", () => { + const summary = settings.summarizeAiSettings(); + expect(summary.enabled).toBe(false); + expect(summary.provider).toEqual({ value: "openai", source: "default" }); + expect(summary.apiKey).toEqual({ configured: false, masked: null, source: "none" }); + expect(summary.model).toEqual({ value: "gpt-4.1-mini", source: "default" }); + expect(summary.baseUrl.source).toBe("default"); + }); + + it("auto-detects the provider from environment keys and honours legacy model names", () => { + process.env.ANTHROPIC_API_KEY = "sk-ant-secret-1234"; + let summary = settings.summarizeAiSettings(); + expect(summary.enabled).toBe(true); + expect(summary.provider).toEqual({ value: "anthropic", source: "env" }); + expect(summary.apiKey).toEqual({ configured: true, masked: "sk-…1234", source: "env" }); + + process.env.OPENAI_API_KEY = "sk-openai-secret-9876"; + process.env.OPENAI_DIGEST_MODEL = "gpt-legacy"; + summary = settings.summarizeAiSettings(); + expect(summary.provider.value).toBe("openai"); + expect(summary.model).toEqual({ value: "gpt-legacy", source: "env" }); + }); + + it("applies generic AI_* variables only to the explicitly selected provider", () => { + process.env.AI_API_KEY = "generic-key-0001"; + process.env.AI_MODEL = "some-model"; + expect(settings.summarizeAiSettings().apiKey.configured).toBe(false); + + process.env.AI_PROVIDER = "custom"; + process.env.AI_BASE_URL = "http://ollama.local:11434/v1/"; + const summary = settings.summarizeAiSettings(); + expect(summary.provider).toEqual({ value: "custom", source: "env" }); + expect(summary.apiKey.source).toBe("env"); + expect(summary.model).toEqual({ value: "some-model", source: "env" }); + expect(summary.baseUrl).toEqual({ value: "http://ollama.local:11434/v1", source: "env" }); + expect(summary.enabled).toBe(true); + }); + + it("lets database overrides win over the environment and reports their source", () => { + process.env.OPENAI_API_KEY = "sk-env-key-4242"; + settings.updateAiSettings({ provider: "openrouter", apiKey: "or-db-key-7777", model: "meta-llama/llama-3-70b" }); + + const summary = settings.summarizeAiSettings(); + expect(summary.provider).toEqual({ value: "openrouter", source: "database" }); + expect(summary.apiKey).toEqual({ configured: true, masked: "or-…7777", source: "database" }); + expect(summary.model).toEqual({ value: "meta-llama/llama-3-70b", source: "database" }); + expect(summary.baseUrl).toEqual({ value: "https://openrouter.ai/api/v1", source: "default" }); + const openai = summary.providers.find((entry) => entry.id === "openai"); + expect(openai?.hasEnvKey).toBe(true); + expect(openai?.hasStoredKey).toBe(false); + }); + + it("removes overrides with empty strings and clears everything on reset", () => { + process.env.OPENAI_API_KEY = "sk-env-key-4242"; + settings.updateAiSettings({ provider: "openai", apiKey: "sk-db-key-1111", model: "gpt-x" }); + expect(settings.summarizeAiSettings().apiKey.source).toBe("database"); + + let summary = settings.updateAiSettings({ apiKey: "" }); + expect(summary.apiKey).toEqual({ configured: true, masked: "sk-…4242", source: "env" }); + expect(summary.model.source).toBe("database"); + + summary = settings.resetAiSettings(); + expect(summary.model).toEqual({ value: "gpt-4.1-mini", source: "default" }); + expect(summary.provider.source).toBe("env"); + }); + + it("keeps per-provider overrides when switching provider", () => { + settings.updateAiSettings({ provider: "gemini", apiKey: "gem-key-0001" }); + settings.updateAiSettings({ provider: "openai" }); + const summary = settings.summarizeAiSettings(); + expect(summary.provider.value).toBe("openai"); + expect(summary.providers.find((entry) => entry.id === "gemini")?.hasStoredKey).toBe(true); + }); + + it("rejects invalid base URLs", () => { + expect(() => settings.updateAiSettings({ provider: "custom", baseUrl: "not a url" })).toThrow(settings.AiSettingsValidationError); + expect(() => settings.updateAiSettings({ provider: "custom", baseUrl: "ftp://x" })).toThrow(/http or https/); + }); +}); diff --git a/tests/utils/goals.test.ts b/tests/utils/goals.test.ts new file mode 100644 index 0000000..c243382 --- /dev/null +++ b/tests/utils/goals.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { calculateGoalProgress, formatXThreadForCopy, groupGoalsByRepository } from "../../src/utils/goals"; +import type { RepositoryGoal } from "../../src/types/goals"; + +describe("groupGoalsByRepository", () => { + it("combines goals for the same repository while preserving order", () => { + const goal = (id: string, repository: string) => ({ id, repository }) as RepositoryGoal; + const groups = groupGoalsByRepository([ + goal("stars", "acme/rocket"), + goal("forks", "other/tool"), + goal("downloads", "acme/rocket"), + ]); + + expect(groups.map((group) => group.repository)).toEqual(["acme/rocket", "other/tool"]); + expect(groups[0].goals.map((entry) => entry.id)).toEqual(["stars", "downloads"]); + }); +}); + +describe("formatXThreadForCopy", () => { + it("joins complete X posts with a visible separator", () => { + expect(formatXThreadForCopy({ content: "fallback", threadPosts: [" First post ", "Second post"] })) + .toBe("First post\n\n---\n\nSecond post"); + }); + + it("uses legacy content when structured posts are absent", () => { + expect(formatXThreadForCopy({ content: " Legacy thread ", threadPosts: [] })).toBe("Legacy thread"); + }); +}); + +describe("calculateGoalProgress", () => { + it("calculates bounded progress and remaining time", () => { + expect(calculateGoalProgress( + { currentValue: 75, targetValue: 100, deadline: "2026-02-10" }, + new Date("2026-02-08T12:00:00Z"), + )).toEqual({ percentage: 75, remaining: 25, daysRemaining: 3, completed: false, overdue: false }); + }); + + it("marks completed goals and caps progress", () => { + const result = calculateGoalProgress( + { currentValue: 120, targetValue: 100, deadline: "2020-01-01" }, + new Date("2026-01-01T00:00:00Z"), + ); + expect(result).toMatchObject({ percentage: 100, remaining: 0, completed: true, overdue: false }); + }); + + it("marks unfinished goals past their deadline as overdue", () => { + const result = calculateGoalProgress( + { currentValue: 2, targetValue: 10, deadline: "2025-12-31" }, + new Date("2026-01-01T00:00:00Z"), + ); + expect(result.overdue).toBe(true); + expect(result.daysRemaining).toBe(0); + }); +}); diff --git a/tests/utils/socialProposals.test.ts b/tests/utils/socialProposals.test.ts new file mode 100644 index 0000000..713930b --- /dev/null +++ b/tests/utils/socialProposals.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { hasCompleteSocialSet, normalizeSocialProposals, socialCharacterCount } from "../../src/utils/socialProposals"; + +describe("socialCharacterCount", () => { + it("counts an emoji as one Unicode code point", () => { + expect(socialCharacterCount("Ship it 🚀")).toBe(9); + }); +}); + +describe("normalizeSocialProposals", () => { + const threadPosts = ["Hook", "Problem", "Approach", "Evidence", "Call to action"]; + + it("returns one valid draft per required platform and rebuilds thread content", () => { + const result = normalizeSocialProposals([ + { title: "X", format: "x-thread", summary: "Developers; README angle.", content: "wrong", threadPosts }, + { title: "LinkedIn", format: "linkedin-post", summary: "Technical leaders; project value.", content: "A professional post.", threadPosts: [] }, + { title: "Mastodon", format: "mastodon-post", summary: "OSS community; contribution angle.", content: "A community post.", threadPosts: [] }, + ]); + + expect(result).toHaveLength(3); + expect(result[0].content).toBe(threadPosts.join("\n\n---\n\n")); + expect(hasCompleteSocialSet(result)).toBe(true); + }); + + it("rejects duplicate formats and posts that exceed platform limits", () => { + const result = normalizeSocialProposals([ + { title: "First", format: "mastodon-post", summary: "Community audience; project angle.", content: "Valid", threadPosts: [] }, + { title: "Duplicate", format: "mastodon-post", summary: "Community audience; project angle.", content: "Also valid", threadPosts: [] }, + { title: "Too long", format: "x-thread", summary: "Developer audience; project angle.", content: "", threadPosts: [...threadPosts.slice(0, 4), "x".repeat(281)] }, + ]); + + expect(result.map((proposal) => proposal.title)).toEqual(["First"]); + expect(hasCompleteSocialSet(result)).toBe(false); + }); +}); From 5eb8aa1735ccfa067e638771e58f63dfd826616b Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Fri, 4 Sep 2026 08:35:15 +0200 Subject: [PATCH 2/2] feat: add repository source libraries to social plans Persist a fixed source library for each repository and expose it through a dedicated Growth Studio modal. Generated platform drafts now reuse source text, attach verified source media per post, identify the active AI model, and include an angle based on recent project updates. --- src/api/github.ts | 26 ++- src/components/common/ContentSourcePicker.tsx | 130 ++++++++++++ .../common/RepositoryContentSources.tsx | 102 ++++++++++ src/components/common/RepositoryPicker.tsx | 11 +- src/components/modals/GoalProposalsModal.tsx | 70 ++++++- src/components/views/GoalsView.tsx | 6 +- src/i18n/en.ts | 20 ++ src/i18n/it.ts | 20 ++ src/server/goalStore.ts | 36 +++- src/server/goals.ts | 185 ++++++++++++++++-- src/server/routes/goals.ts | 15 +- src/server/routes/repository.ts | 22 ++- src/styles/goals.css | 63 +++++- src/types/goals.ts | 14 ++ src/utils/socialProposals.ts | 131 ++++++++++++- tests/utils/socialProposals.test.ts | 60 +++++- 16 files changed, 867 insertions(+), 44 deletions(-) create mode 100644 src/components/common/ContentSourcePicker.tsx create mode 100644 src/components/common/RepositoryContentSources.tsx diff --git a/src/api/github.ts b/src/api/github.ts index 4bb1f42..e6a982d 100644 --- a/src/api/github.ts +++ b/src/api/github.ts @@ -1,7 +1,7 @@ import { interpretUpstreamJson } from "../utils/upstreamResponse"; import { getEtag, peek, setEtag, swr } from "./cache"; import type { AiConnectionTest, AiSettingsSummary, AiSettingsUpdate } from "../types/ai"; -import type { GoalMetric, GoalProposalsData, GoalsData, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import type { GoalContentSource, GoalMetric, GoalProposalsData, GoalsData, GoalSuggestion, RepositoryGoal } from "../types/goals"; import type { ApiError, CIHealthData, @@ -214,9 +214,29 @@ export function generateGoalAdvice(id: string): Promise<{ ok: true; suggestions: return readJson(`/api/goals/${encodeURIComponent(id)}/advice`, { method: "POST" }); } -export function fetchGoalProposals(goalId: string, suggestionIndex: number, refresh = false): Promise { +export function fetchRepositoryContentSources(repository: string): Promise<{ ok: true; sources: GoalContentSource[] }> { + return readJson(`/api/repository-content-sources?repo=${encodeURIComponent(repository)}`); +} + +export function updateRepositoryContentSources(repository: string, sources: GoalContentSource[]): Promise<{ ok: true; sources: GoalContentSource[] }> { + return readJson(`/api/repository-content-sources?repo=${encodeURIComponent(repository)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sources }), + }); +} + +export function fetchGoalProposals( + goalId: string, + suggestionIndex: number, + refresh = false, +): Promise { const query = refresh ? "?refresh=1" : ""; - return readJson(`/api/goals/${encodeURIComponent(goalId)}/suggestions/${suggestionIndex}/proposals${query}`, { method: "POST" }); + return readJson(`/api/goals/${encodeURIComponent(goalId)}/suggestions/${suggestionIndex}/proposals${query}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); } export function fetchAiSettings(): Promise<{ ok: true; settings: AiSettingsSummary }> { diff --git a/src/components/common/ContentSourcePicker.tsx b/src/components/common/ContentSourcePicker.tsx new file mode 100644 index 0000000..b56b0ce --- /dev/null +++ b/src/components/common/ContentSourcePicker.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { GoalContentSource } from "../../types/goals"; +import type { GhRepo } from "../../types/github"; +import { normalizeContentSources } from "../../utils/socialProposals"; +import { BookIcon } from "./Icons"; +import { RepositoryPicker } from "./RepositoryPicker"; + +interface ContentSourcePickerProps { + repos: GhRepo[]; + currentRepository: string; + value: GoalContentSource[]; + onChange: (sources: GoalContentSource[]) => void; + maxSources?: number; +} + +type SourceMode = "repository" | "website"; + +/** Selects optional campaign sources without duplicating repository-picker behavior. */ +export function ContentSourcePicker({ repos, currentRepository, value, onChange, maxSources = 6 }: ContentSourcePickerProps) { + const { t } = useI18n(); + const [mode, setMode] = useState("repository"); + const [repository, setRepository] = useState(""); + const [website, setWebsite] = useState(""); + const [error, setError] = useState(""); + const full = value.length >= maxSources; + const availableRepos = useMemo(() => full ? [] : repos.filter((repo) => ( + repo.nameWithOwner !== currentRepository + && !value.some((source) => source.type === "repository" && source.value === repo.nameWithOwner) + )), [currentRepository, full, repos, value]); + + function add(source: GoalContentSource): boolean { + const normalized = normalizeContentSources([...value, source], maxSources); + if (normalized.length === value.length) { + setError(t("goals.sourcesInvalid")); + return false; + } + onChange(normalized); + setError(""); + return true; + } + + useEffect(() => { + if (!repository) return; + add({ type: "repository", value: repository }); + setRepository(""); + // `add` intentionally reacts only to an explicit picker selection. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repository]); + + function addWebsite() { + if (add({ type: "website", value: website })) setWebsite(""); + } + + function handleWebsiteKeyDown(event: KeyboardEvent) { + if (event.key !== "Enter") return; + event.preventDefault(); + addWebsite(); + } + + return ( +
+
+ +
+ {t("goals.sourcesTitle")} + {t("goals.sourcesDescription")} +
+ {value.length}/{maxSources} +
+ +
+
+ + +
+
+ {mode === "repository" ? ( + + ) : ( +
+ + setWebsite(event.target.value)} + onKeyDown={handleWebsiteKeyDown} + /> + +
+ )} +
+
+ + {value.length ? ( +
+ {value.map((source) => ( + + {source.type === "repository" ? t("goals.sourcesRepoBadge") : t("goals.sourcesWebBadge")} + {source.value} + + + ))} +
+ ) : ( +

{t("goals.sourcesOptional")}

+ )} + {error ? {error} : null} +
+ ); +} diff --git a/src/components/common/RepositoryContentSources.tsx b/src/components/common/RepositoryContentSources.tsx new file mode 100644 index 0000000..38bea40 --- /dev/null +++ b/src/components/common/RepositoryContentSources.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { fetchRepositoryContentSources, updateRepositoryContentSources } from "../../api/github"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { GoalContentSource } from "../../types/goals"; +import type { GhRepo } from "../../types/github"; +import { ContentSourcePicker } from "./ContentSourcePicker"; +import { BookIcon, CloseIcon } from "./Icons"; + +interface RepositoryContentSourcesProps { + repository: string; + repos: GhRepo[]; +} + +/** Opens the fixed source library shared by every generated post for a repository. */ +export function RepositoryContentSources({ repository, repos }: RepositoryContentSourcesProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const saveQueue = useRef>(Promise.resolve()); + const saveVersion = useRef(0); + + useEffect(() => { + if (!open) return; + let active = true; + saveVersion.current += 1; + setError(""); + setLoading(true); + setSaving(false); + void fetchRepositoryContentSources(repository) + .then((result) => { if (active) setSources(result.sources); }) + .catch((cause) => { if (active) setError((cause as Error).message); }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [open, repository]); + + useEffect(() => { + if (!open) return; + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [open]); + + function changeSources(next: GoalContentSource[]) { + setSources(next); + setError(""); + setSaving(true); + const version = ++saveVersion.current; + const request = saveQueue.current.then(async () => { + await updateRepositoryContentSources(repository, next); + }); + saveQueue.current = request.catch(() => undefined); + void request.catch((cause) => { + if (version === saveVersion.current) setError((cause as Error).message); + }).finally(() => { + if (version === saveVersion.current) setSaving(false); + }); + } + + return ( + <> + + {open ? createPortal( +
+
setOpen(false)} /> +
+
+
+ +
+
{repository}
+

{t("goals.sourcesTitle")}

+
+
+ +
+
+ {loading ?
{t("common.loading")}
: ( + + )} + {saving ? {t("common.loading")} : null} + {error ? {error} : null} +
+
+ {saving ? t("common.loading") : ""} +
+ +
+
+
, + document.body, + ) : null} + + ); +} diff --git a/src/components/common/RepositoryPicker.tsx b/src/components/common/RepositoryPicker.tsx index fbcc32b..1d48652 100644 --- a/src/components/common/RepositoryPicker.tsx +++ b/src/components/common/RepositoryPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { GhRepo } from "../../types/github"; import { formatNumber } from "../../utils/format"; import { BookIcon } from "./Icons"; @@ -12,6 +12,7 @@ interface RepositoryPickerProps { export function RepositoryPicker({ repos, value, placeholder, onChange }: RepositoryPickerProps) { const rootRef = useRef(null); + const optionsId = useId(); const [query, setQuery] = useState(value); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); @@ -46,8 +47,8 @@ export function RepositoryPicker({ repos, value, placeholder, onChange }: Reposi type="search" role="combobox" aria-expanded={open} - aria-controls="repository-picker-options" - aria-activedescendant={open && matches[activeIndex] ? `repo-option-${activeIndex}` : undefined} + aria-controls={optionsId} + aria-activedescendant={open && matches[activeIndex] ? `${optionsId}-option-${activeIndex}` : undefined} autoComplete="off" placeholder={placeholder} value={query} @@ -68,14 +69,14 @@ export function RepositoryPicker({ repos, value, placeholder, onChange }: Reposi
{open ? ( -
+
{matches.length ? `${matches.length} repositories` : "No repositories found"}
{matches.map((repo, index) => (
@@ -165,12 +216,17 @@ export function GoalProposalsModal({ goal, suggestion, suggestionIndex, onClose, {state.kind === "ready" && state.generatedAt ? t("goals.proposalsGeneratedAt", { time: formatRelativeTime(state.generatedAt, Date.now(), language) }) : ""}
- {state.kind === "ready" || state.kind === "error" ? ( + {state.kind === "ready" ? ( ) : null} - + + {state.kind === "idle" || state.kind === "error" ? ( + + ) : null}
, diff --git a/src/components/views/GoalsView.tsx b/src/components/views/GoalsView.tsx index d07e92e..2a92075 100644 --- a/src/components/views/GoalsView.tsx +++ b/src/components/views/GoalsView.tsx @@ -4,6 +4,7 @@ import { createGoal, deleteGoal, generateGoalAdvice } from "../../api/github"; import { useI18n } from "../../i18n/I18nProvider"; import { Avatar } from "../common/Avatar"; import { ConfirmDialog } from "../common/ConfirmDialog"; +import { RepositoryContentSources } from "../common/RepositoryContentSources"; import { RepositoryPicker } from "../common/RepositoryPicker"; import { GoalIcon } from "../common/Icons"; import { GoalProposalsModal } from "../modals/GoalProposalsModal"; @@ -178,7 +179,10 @@ export function GoalsView({ goals, repos, loading, onChange }: GoalsViewProps) {
{t("goals.growthStudioEyebrow")}

{t("goals.growthStudio")}

-

{t("goals.growthStudioDescription")}

+
+

{t("goals.growthStudioDescription")}

+ +
{group.goals.map((goal) => ( diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9d9f2bc..08863ce 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -177,8 +177,28 @@ export const en = { "goals.proposalsOpen": "Get proposals", "goals.proposalsKind": "Recommended action", "goals.proposalsIntro": "Ready-to-use drafts for this action, based on the README and current activity of {repo}.", + "goals.sourcesTitle": "Repository sources", + "goals.sourcesDescription": "Set the repository's shared source library. Each post can use different text, images, and videos from these fixed project sources. External reuse rights must be checked before publishing.", + "goals.sourcesRepository": "Repository source", + "goals.sourcesChooseRepository": "Choose another repository…", + "goals.sourcesWebsite": "Website source", + "goals.sourcesAdd": "Add", + "goals.sourcesRepoBadge": "Repo", + "goals.sourcesWebBadge": "Web", + "goals.sourcesRemove": "Remove {source}", + "goals.sourcesInvalid": "Enter a valid, non-duplicate website or repository.", + "goals.sourcesLimit": "Maximum number of sources reached", + "goals.sourcesOptional": "Optional — saved for this project and shared by all its posts. The project repository remains the primary source.", + "goals.mediaTitle": "Suggested visual assets", + "goals.mediaImage": "Image", + "goals.mediaVideo": "Video", + "goals.proposalsReadyTitle": "Ready when you are", + "goals.proposalsReadyText": "Review the action, then generate drafts using the repository's fixed source library.", + "goals.proposalsGenerate": "Generate proposals", + "goals.proposalsRetry": "Try again", "goals.proposalsLoading": "Reading the project and drafting proposals…", "goals.proposalsRegenerate": "Regenerate", + "goals.proposalsRegenerateSources": "Regenerate with sources", "goals.proposalsGeneratedAt": "Generated {time}", "goals.proposalsNoAi": "Configure an AI provider in Preferences to get proposals.", "goals.proposalsOpenPreferences": "Open preferences", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 08dbeff..9ff6f54 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -179,8 +179,28 @@ export const it: Record = { "goals.proposalsOpen": "Ottieni proposte", "goals.proposalsKind": "Intervento consigliato", "goals.proposalsIntro": "Bozze pronte all'uso per questo intervento, basate sul README e sull'attività attuale di {repo}.", + "goals.sourcesTitle": "Fonti della repository", + "goals.sourcesDescription": "Imposta le fonti condivise della repository. Ogni post può riprendere testi, immagini e video diversi da queste fonti fisse per il progetto. Verifica i diritti di riutilizzo prima della pubblicazione.", + "goals.sourcesRepository": "Repository sorgente", + "goals.sourcesChooseRepository": "Scegli un'altra repository…", + "goals.sourcesWebsite": "Sito web sorgente", + "goals.sourcesAdd": "Aggiungi", + "goals.sourcesRepoBadge": "Repo", + "goals.sourcesWebBadge": "Web", + "goals.sourcesRemove": "Rimuovi {source}", + "goals.sourcesInvalid": "Inserisci un sito o una repository validi e non duplicati.", + "goals.sourcesLimit": "Numero massimo di fonti raggiunto", + "goals.sourcesOptional": "Facoltativo — vengono salvate per il progetto e condivise da tutti i suoi post. La repository resta la fonte principale.", + "goals.mediaTitle": "Contenuti visuali suggeriti", + "goals.mediaImage": "Immagine", + "goals.mediaVideo": "Video", + "goals.proposalsReadyTitle": "Tutto pronto", + "goals.proposalsReadyText": "Controlla l'intervento e genera le bozze usando la libreria di fonti fisse della repository.", + "goals.proposalsGenerate": "Genera proposte", + "goals.proposalsRetry": "Riprova", "goals.proposalsLoading": "Sto leggendo il progetto e preparando le proposte…", "goals.proposalsRegenerate": "Rigenera", + "goals.proposalsRegenerateSources": "Rigenera con le fonti", "goals.proposalsGeneratedAt": "Generate {time}", "goals.proposalsNoAi": "Configura un provider AI nelle Preferenze per ottenere proposte.", "goals.proposalsOpenPreferences": "Apri preferenze", diff --git a/src/server/goalStore.ts b/src/server/goalStore.ts index 29303e4..e8abfe7 100644 --- a/src/server/goalStore.ts +++ b/src/server/goalStore.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import type { GoalContentSource, GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; import { all, get, getDatabase, run } from "./sqlite"; interface GoalRow { @@ -33,6 +33,13 @@ function ensureSchema(): void { ); CREATE INDEX IF NOT EXISTS repository_goals_account_deadline ON repository_goals(account_id, deadline); + CREATE TABLE IF NOT EXISTS repository_content_sources ( + account_id TEXT NOT NULL, + repository TEXT NOT NULL, + sources TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + PRIMARY KEY (account_id, repository) + ); `); } @@ -90,6 +97,33 @@ export function updateGoalCurrentValue(accountId: string, id: string, currentVal run("UPDATE repository_goals SET current_value = ?, updated_at = ? WHERE account_id = ? AND id = ?", [currentValue, new Date().toISOString(), accountId, id]); } +/** Returns the shared source library used by every goal and post for a repository. */ +export function getRepositoryContentSources(accountId: string, repository: string): GoalContentSource[] { + ensureSchema(); + const row = get<{ sources: string }>( + "SELECT sources FROM repository_content_sources WHERE account_id = ? AND repository = ?", + [accountId, repository], + ); + if (!row) return []; + try { + const sources = JSON.parse(row.sources) as unknown; + return Array.isArray(sources) ? sources as GoalContentSource[] : []; + } catch { + return []; + } +} + +/** Replaces a repository's fixed source library. Inputs are normalized by the route. */ +export function saveRepositoryContentSources(accountId: string, repository: string, sources: GoalContentSource[]): void { + ensureSchema(); + run( + `INSERT INTO repository_content_sources (account_id, repository, sources, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(account_id, repository) DO UPDATE SET sources = excluded.sources, updated_at = excluded.updated_at`, + [accountId, repository, JSON.stringify(sources), new Date().toISOString()], + ); +} + export function saveGoalSuggestions(accountId: string, id: string, suggestions: GoalSuggestion[]): void { ensureSchema(); const now = new Date().toISOString(); diff --git a/src/server/goals.ts b/src/server/goals.ts index d599f59..9d3a25e 100644 --- a/src/server/goals.ts +++ b/src/server/goals.ts @@ -1,6 +1,15 @@ -import type { GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import type { GoalContentSource, GoalMetric, GoalProposal, GoalSuggestion, RepositoryGoal } from "../types/goals"; import { calculateGoalProgress } from "../utils/goals"; -import { hasCompleteSocialSet, normalizeSocialProposals, SOCIAL_PROPOSAL_FORMATS } from "../utils/socialProposals"; +import { + attachSourceMedia, + extractMediaUrls, + extractWebPageSignal, + hasCompleteSocialSet, + normalizeSocialProposals, + SOCIAL_PROPOSAL_FORMATS, +} from "../utils/socialProposals"; import { AiNotConfiguredError, AiRequestError, generateStructured } from "./ai/client"; import { isAiConfigured } from "./ai/settings"; import { getIssuesCached, getPullRequestsCached, getReposCached } from "./dashboardData"; @@ -71,17 +80,23 @@ function fallbackSuggestions(goal: Omit): GoalSugge { category: "marketing", title: "Publish a complete X launch thread", - action: `Tell the story of ${goal.repository} in a 5–7 post X thread: open with a concrete hook, show what the project solves, highlight recent work, share the ${progress.percentage}% goal progress, and close with one clear call to action.`, + action: `Tell the story of ${goal.repository} in a 5–7 post X thread: open with a concrete hook, show what the project solves, highlight recent work, share the ${progress.percentage}% goal progress, and close with one clear call to action.`, + }, + { + category: "marketing", + title: "Build the next campaign from the latest updates", + action: "Use the latest verified release notes, issue activity, and merged work as the campaign narrative. Explain what changed, why it matters, and invite the community to try it or contribute without claiming that unfinished work has shipped.", }, ]; } export async function generateGoalSuggestions(goal: Omit): Promise { if (!isAiConfigured()) return fallbackSuggestions(goal); - const [issuesResult, prsResult, reposResult] = await Promise.all([ + const [issuesResult, prsResult, reposResult, releases] = await Promise.all([ getIssuesCached(false), getPullRequestsCached(false), getReposCached(false), + fetchReleaseSignals(goal.repository), ]); const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; @@ -90,7 +105,7 @@ export async function generateGoalSuggestions(goal: Omit Date.now() - new Date(item.updatedAt).getTime() > 30 * 86_400_000).length; const result = await generateStructured<{ suggestions: GoalSuggestion[] }>({ - instructions: "Act as an open-source growth and social strategist. Give specific, ethical actions grounded in the supplied activity. Include at least one substantial social campaign idea designed as a complete 5–7 post X thread, not a generic one-line post. Give it a strong hook, a useful narrative arc, concrete project details, and one clear call to action. Return JSON only.", + instructions: "Act as an open-source growth and social strategist. Give specific, ethical actions grounded in the supplied activity. Include at least one substantial social campaign idea designed as a complete 5–7 post X thread, not a generic one-line post. Also include one recommendation explicitly based on the latest verified updates (releases, recently updated issues, or pull requests), clearly framing unfinished work as work in progress. Give it a strong hook, a useful narrative arc, concrete project details, and one clear call to action. Return JSON only.", input: JSON.stringify({ repository: goal.repository, description: repo?.description, @@ -102,8 +117,13 @@ export async function generateGoalSuggestions(goal: Omit item.title), - recentPullRequestTitles: prs.slice(0, 5).map((item) => item.title), + recentIssues: issues.slice(0, 8).map((item) => ({ title: item.title, updatedAt: item.updatedAt })), + recentPullRequests: prs.slice(0, 5).map((item) => ({ title: item.title, updatedAt: item.updatedAt })), + latestReleases: releases.map((release) => ({ + name: release.name || release.tag_name || null, + publishedAt: release.published_at ?? null, + notesExcerpt: release.body?.replace(/\s+/g, " ").trim().slice(0, 350) || null, + })), }), schemaName: "goal_actions", schema: { @@ -134,7 +154,7 @@ export async function generateGoalSuggestions(goal: Omit } } -async function fetchReadmeExcerpt(repository: string): Promise { +interface ReadmeSignal { + excerpt: string; + mediaUrls: string[]; +} + +async function fetchReadmeSignal(repository: string): Promise { try { - const result = await restApi<{ content?: string; encoding?: string }>(`/repos/${repository}/readme`); + const result = await restApi<{ content?: string; encoding?: string; download_url?: string | null }>(`/repos/${repository}/readme`); if (!result.ok || !result.data?.content) return null; const text = result.data.encoding === "base64" ? Buffer.from(result.data.content, "base64").toString("utf-8") : result.data.content; - return text.replace(/\r/g, "").trim().slice(0, README_EXCERPT_CHARS) || null; + return { + excerpt: text.replace(/\r/g, "").trim().slice(0, README_EXCERPT_CHARS), + mediaUrls: extractMediaUrls(text, result.data.download_url), + }; } catch { return null; } } +function isPrivateAddress(address: string): boolean { + if (isIP(address) === 4) { + const [a, b] = address.split(".").map(Number); + return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) + || (a === 198 && (b === 18 || b === 19)) || a >= 224; + } + const normalized = address.toLowerCase(); + return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") + || /^fe[89ab]/.test(normalized) || normalized.startsWith("::ffff:") && isPrivateAddress(normalized.slice(7)); +} + +async function assertPublicWebsite(url: URL): Promise { + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error("unsupported source URL"); + if (url.hostname === "localhost" || url.hostname.endsWith(".localhost")) throw new Error("private source URL"); + const addresses = isIP(url.hostname) + ? [{ address: url.hostname }] + : await lookup(url.hostname, { all: true, verbatim: true }); + if (!addresses.length || addresses.some((entry) => isPrivateAddress(entry.address))) throw new Error("private source URL"); +} + +async function readBoundedText(response: Response, maxBytes = 600_000): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { await reader.cancel(); break; } + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); +} + +async function fetchWebsiteSignal(value: string): Promise { + try { + let url = new URL(value); + for (let redirects = 0; redirects <= 3; redirects += 1) { + await assertPublicWebsite(url); + const response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(7_000), + headers: { Accept: "text/html,text/plain,image/*,video/*", "User-Agent": "GitDeck/1.0 source-reader" }, + }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location || redirects === 3) throw new Error("too many source redirects"); + url = new URL(location, url); + continue; + } + if (!response.ok) throw new Error(`source returned ${response.status}`); + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.startsWith("image/") || contentType.startsWith("video/")) { + return { type: "website", url: value, title: null, excerpt: null, mediaUrls: [url.toString()] }; + } + if (!contentType.includes("text/html") && !contentType.includes("text/plain")) throw new Error("unsupported source content"); + const page = extractWebPageSignal(await readBoundedText(response), url.toString()); + return { type: "website", url: value, title: page.title, excerpt: page.excerpt, mediaUrls: page.mediaUrls }; + } + } catch (error) { + return { type: "website", url: value, error: (error as Error).message }; + } + return { type: "website", url: value, error: "source unavailable" }; +} + +async function fetchAdditionalSourceSignals(sources: GoalContentSource[]): Promise { + return Promise.all(sources.map(async (source) => { + if (source.type === "website") return fetchWebsiteSignal(source.value); + const [readme, releases] = await Promise.all([ + fetchReadmeSignal(source.value), + fetchReleaseSignals(source.value), + ]); + return { + type: source.type, + repository: source.value, + readmeExcerpt: readme?.excerpt ?? null, + mediaUrls: readme?.mediaUrls ?? [], + releases: releases.map((release) => ({ + name: release.name || release.tag_name || null, + url: release.html_url ?? null, + publishedAt: release.published_at ?? null, + notesExcerpt: release.body?.replace(/\s+/g, " ").trim().slice(0, 500) || null, + })), + }; + })); +} + /** * Turns one recommended action into concrete, ready-to-use deliverables * (posts, issue drafts, checklists…) grounded in the repository's README and * current activity. Requires a configured AI provider. */ -export async function generateGoalProposals(goal: Omit, suggestion: GoalSuggestion): Promise { +export async function generateGoalProposals( + goal: Omit, + suggestion: GoalSuggestion, + sources: GoalContentSource[] = [], +): Promise { if (!isAiConfigured()) throw new AiNotConfiguredError(); - const [issuesResult, prsResult, reposResult, readme, releases] = await Promise.all([ + const [issuesResult, prsResult, reposResult, readme, releases, additionalSources] = await Promise.all([ getIssuesCached(false), getPullRequestsCached(false), getReposCached(false), - fetchReadmeExcerpt(goal.repository), + fetchReadmeSignal(goal.repository), fetchReleaseSignals(goal.repository), + fetchAdditionalSourceSignals(sources), ]); const issues = issuesResult.ok ? issuesResult.issues.filter((item) => item.repository.nameWithOwner === goal.repository) : []; const prs = prsResult.ok ? prsResult.pullRequests.filter((item) => item.repository.nameWithOwner === goal.repository) : []; @@ -202,18 +325,21 @@ export async function generateGoalProposals(goal: Omit { + if (!source || typeof source !== "object") return []; + const mediaUrls = (source as { mediaUrls?: unknown }).mediaUrls; + return Array.isArray(mediaUrls) ? mediaUrls.filter((url): url is string => typeof url === "string") : []; + }), + ]; + if (proposals.length === 3 && hasCompleteSocialSet(proposals)) return attachSourceMedia(proposals, sourceMedia); feedback = "The previous answer was not publishable. Return all three required formats exactly once; use 5–7 X posts of at most 280 characters, LinkedIn content of at most 3000 characters, and Mastodon content of at most 500 characters."; } throw new AiRequestError("AI returned incomplete or platform-invalid social proposals"); diff --git a/src/server/routes/goals.ts b/src/server/routes/goals.ts index 77fa9fd..7f9d3ac 100644 --- a/src/server/routes/goals.ts +++ b/src/server/routes/goals.ts @@ -1,5 +1,13 @@ import { getActive as getActiveAccount } from "../accountStore"; -import { createGoal, deleteGoal, findGoal, listGoals, saveGoalProposals, saveGoalSuggestions } from "../goalStore"; +import { + createGoal, + deleteGoal, + findGoal, + getRepositoryContentSources, + listGoals, + saveGoalProposals, + saveGoalSuggestions, +} from "../goalStore"; import { isAiConfigured } from "../ai/settings"; import { AiNotConfiguredError, AiRequestError } from "../ai/client"; import { generateGoalProposals, generateGoalSuggestions, refreshGoal, SOCIAL_PROPOSALS_VERSION } from "../goals"; @@ -76,12 +84,15 @@ async function proposals(ctx: RouteContext): Promise { const index = Number(ctx.params.index); const suggestion = Number.isInteger(index) ? goal.suggestions[index] : undefined; if (!suggestion) return sendJson(ctx.res, 404, { ok: false, error: "suggestion not found" }); + const body = await parseJsonBody>(ctx.req, ctx.res); + if (!body) return; + const sources = getRepositoryContentSources(account.id, goal.repository); const refresh = ctx.url.searchParams.get("refresh") === "1"; if (!refresh && suggestion.proposals?.length && suggestion.proposalsVersion === SOCIAL_PROPOSALS_VERSION) { return sendJson(ctx.res, 200, { ok: true, proposals: suggestion.proposals, generatedAt: suggestion.proposalsGeneratedAt, cached: true }); } try { - const generated = await generateGoalProposals(goal, suggestion); + const generated = await generateGoalProposals(goal, suggestion, sources); if (!generated.length) return sendJson(ctx.res, 502, { ok: false, error: "AI returned no proposals" }); const saved = saveGoalProposals(account.id, goal.id, index, generated, SOCIAL_PROPOSALS_VERSION); sendJson(ctx.res, 200, { ok: true, proposals: generated, generatedAt: saved?.proposalsGeneratedAt ?? new Date().toISOString(), cached: false }); diff --git a/src/server/routes/repository.ts b/src/server/routes/repository.ts index a475abb..afb83b6 100644 --- a/src/server/routes/repository.ts +++ b/src/server/routes/repository.ts @@ -1,5 +1,8 @@ import type { RepoSecuritySummary } from "../../types/github"; +import { normalizeContentSources } from "../../utils/socialProposals"; +import { getActive as getActiveAccount } from "../accountStore"; import { ghApiJson, gql, restApiPaginate, type RestResult } from "../githubClient"; +import { getRepositoryContentSources, saveRepositoryContentSources } from "../goalStore"; import { getLatestRepoDigest } from "../digests"; import { BRANCHES_QUERY, @@ -9,7 +12,7 @@ import { REPO_COUNTS_QUERY, STARGAZERS_QUERY, } from "../graphql/repositoryQueries"; -import { sendJson } from "../http"; +import { parseJsonBody, sendJson } from "../http"; import type { AppRouter, RouteContext } from "../router"; import { fetchRepoSecuritySummary } from "../securityAlerts"; import { requireRepo, requireRepoParts, sendError } from "./shared"; @@ -302,10 +305,27 @@ async function details(ctx: RouteContext): Promise { }); } +async function contentSources(ctx: RouteContext): Promise { + const account = await getActiveAccount(); + if (!account) return sendJson(ctx.res, 401, { ok: false, needsAuth: true, error: "authentication required" }); + const repository = requireRepo(ctx); + if (!repository) return; + if (ctx.req.method === "GET") { + return sendJson(ctx.res, 200, { ok: true, sources: getRepositoryContentSources(account.id, repository) }); + } + const body = await parseJsonBody<{ sources?: unknown }>(ctx.req, ctx.res); + if (!body) return; + const sources = normalizeContentSources(body.sources); + saveRepositoryContentSources(account.id, repository, sources); + sendJson(ctx.res, 200, { ok: true, sources }); +} + export function registerRepositoryRoutes(router: AppRouter): void { router.get("/api/stargazers", stargazers); router.get("/api/forks", forks); router.get("/api/repo-branches", branches); router.get("/api/repo-discussions", discussions); router.get("/api/repo-details", details); + router.get("/api/repository-content-sources", contentSources); + router.on("PUT", "/api/repository-content-sources", contentSources); } diff --git a/src/styles/goals.css b/src/styles/goals.css index df4f810..8c2b89a 100644 --- a/src/styles/goals.css +++ b/src/styles/goals.css @@ -17,8 +17,8 @@ .repository-picker-input { display: flex; align-items: center; min-height: 36px; padding: 0 9px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; transition: border-color .12s, box-shadow .12s; } .repository-picker-input.open { border-color: var(--accent); box-shadow: var(--ring); } .repository-picker-input > svg { width: 14px; height: 14px; flex: 0 0 auto; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; } -.goal-form .repository-picker-input input { min-width: 0; min-height: 34px; padding: 6px 8px; background: transparent; border: 0; box-shadow: none; } -.goal-form .repository-picker-input input:focus { border: 0; box-shadow: none; } +.repository-picker-input input { width: 100%; min-width: 0; min-height: 34px; padding: 6px 8px; color: var(--text); background: transparent; border: 0; outline: 0; box-shadow: none; } +.repository-picker-input input:focus { border: 0; outline: 0; box-shadow: none; } .repository-picker-chevron { color: var(--muted); font-size: 15px; } .repository-picker-menu { position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; width: max(100%, 430px); max-width: min(90vw, 560px); max-height: 390px; overflow-y: auto; padding: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 14px 40px rgba(0,0,0,.35); } .repository-picker-summary { padding: 6px 8px 8px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } @@ -103,6 +103,11 @@ .goal-studio-heading span { color: var(--accent); font-size: 9px; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } .goal-studio-heading h3 { margin: 2px 0 0; font-size: 15px; } .goal-studio-heading p { max-width: 520px; margin: 0; color: var(--muted); font-size: 11px; text-align: right; } +.goal-studio-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; } +.goal-sources-open { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.goal-sources-open svg { width: 14px; height: 14px; } +.modal.goal-sources-modal { width: min(720px, calc(100vw - 32px)); height: auto; max-height: min(86vh, 720px); } +.goal-sources-body { display: grid; gap: 10px; padding: 18px; } .goal-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 10px; } .goal-plan { overflow: hidden; background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--border-soft); border-radius: 12px; } .goal-plan-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; background: linear-gradient(90deg, var(--accent-faint), transparent); border-bottom: 1px solid var(--border-soft); } @@ -136,6 +141,7 @@ @media (max-width: 900px) { .goal-form { grid-template-columns: 1fr 1fr; } .goal-studio-heading { display: grid; } + .goal-studio-actions { align-items: flex-start; justify-content: space-between; } .goal-studio-heading p { text-align: left; } } @media (max-width: 560px) { @@ -145,14 +151,54 @@ .goal-repository-score small { display: none; } .goal-track-grid { padding: 10px; } .goal-growth-studio { padding: 15px 10px; } + .goal-studio-actions { display: grid; justify-items: start; } } /* Proposals modal */ .modal.goal-proposals-modal { width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(90vh, 960px); } .goal-proposals-category { color: var(--accent-2); text-transform: uppercase; } +.goal-proposals-model { color: var(--muted); font-weight: 600; text-transform: none; } .goal-proposals-body { display: grid; gap: 14px; padding: 18px; } .goal-proposals-intro { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.5; } .goal-proposals-action { margin: 0; padding: 10px 14px; border-left: 3px solid var(--accent); border-radius: 0 8px 8px 0; background: var(--panel-2); color: var(--text); font-size: 12.5px; line-height: 1.5; } +.content-source-picker { display: grid; gap: 11px; padding: 14px; border: 1px solid var(--border-soft); border-radius: 12px; background: linear-gradient(145deg, color-mix(in srgb, var(--panel-2) 82%, var(--accent) 3%), var(--panel-2)); } +.content-source-head { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; } +.content-source-icon { display: grid; place-items: center; width: 34px; height: 34px; color: var(--accent-2); background: var(--accent-faint); border: 1px solid var(--accent-border); border-radius: 9px; } +.content-source-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.content-source-head > div { display: grid; gap: 2px; } +.content-source-head strong { font-size: 12.5px; } +.content-source-head small { max-width: 680px; color: var(--muted); font-size: 10.5px; line-height: 1.4; } +.content-source-count { align-self: start; padding: 3px 7px; color: var(--muted); background: var(--panel); border: 1px solid var(--border-soft); border-radius: 999px; font-size: 9.5px; } +.content-source-compose { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; } +.content-source-tabs { display: inline-flex; padding: 3px; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; } +.content-source-tabs button { display: inline-flex; align-items: center; gap: 5px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 10.5px; font-weight: 700; cursor: pointer; } +.content-source-tabs button[aria-selected="true"] { color: var(--text); background: var(--hover-surface); box-shadow: 0 1px 3px rgba(0,0,0,.18); } +.content-source-tabs svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; } +.content-source-control { min-width: 0; } +.content-source-control .repository-picker-input, .content-source-url { min-height: 36px; background: var(--panel); border-radius: 9px; } +.content-source-control .repository-picker-input input, .content-source-url input { font-size: 12px; } +.content-source-url { display: flex; align-items: center; padding-left: 10px; border: 1px solid var(--border); transition: border-color .12s, box-shadow .12s; } +.content-source-url:focus-within { border-color: var(--accent); box-shadow: var(--ring); } +.content-source-url > svg { width: 15px; height: 15px; flex: 0 0 auto; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; } +.content-source-url input { width: 100%; min-width: 0; height: 34px; padding: 6px 9px; color: var(--text); background: transparent; border: 0; outline: 0; } +.content-source-url > button { width: 28px; height: 28px; margin-right: 4px; flex: 0 0 auto; color: var(--panel); background: var(--accent); border: 0; border-radius: 7px; font-size: 17px; line-height: 1; cursor: pointer; } +.content-source-url > button:disabled { opacity: .35; cursor: default; } +.content-source-list { display: flex; flex-wrap: wrap; gap: 6px; } +.content-source-list > span { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 5px 4px 8px; color: var(--muted); background: var(--panel); border: 1px solid var(--border-soft); border-radius: 8px; font-size: 10.5px; } +.content-source-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.content-source-list b { color: var(--accent-2); font-size: 8px; letter-spacing: .04em; text-transform: uppercase; } +.content-source-list button { display: grid; place-items: center; width: 19px; height: 19px; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; } +.content-source-list button:hover { color: var(--text); background: var(--hover-surface); } +.content-source-empty { margin: -2px 0 0; color: var(--muted-2); font-size: 10px; } +.content-source-error { color: var(--danger); font-size: 10.5px; } +.content-source-status { margin-top: -9px; color: var(--muted-2); font-size: 10px; } +.goal-proposals-start { display: flex; align-items: center; justify-content: center; gap: 11px; min-height: 92px; padding: 18px; color: var(--muted); border: 1px dashed var(--border); border-radius: 11px; background: color-mix(in srgb, var(--panel-2) 45%, transparent); text-align: left; } +.goal-proposals-start > span { display: grid; place-items: center; width: 36px; height: 36px; flex: 0 0 auto; color: var(--accent); background: var(--accent-faint); border-radius: 50%; } +.goal-proposals-start svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; } +.goal-proposals-start > div { display: grid; gap: 3px; } +.goal-proposals-start strong { color: var(--text); font-size: 12px; } +.goal-proposals-start small { font-size: 10.5px; line-height: 1.4; } +.goal-proposals-generate { min-width: 140px; } .goal-proposals-loading { display: flex; align-items: center; gap: 10px; padding: 26px 0; color: var(--muted); font-size: 13px; } .goal-proposals-spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: goalSpin .8s linear infinite; } @keyframes goalSpin { to { transform: rotate(360deg); } } @@ -176,6 +222,17 @@ .goal-proposal-content > :last-child { margin-bottom: 0; } .goal-proposal-content .task-list-item { flex-wrap: wrap; } .goal-proposal-content .task-list-item > ul, .goal-proposal-content .task-list-item > ol { flex-basis: 100%; margin-left: 22px; } +.goal-proposal-media { display: grid; gap: 8px; padding: 11px 14px 14px; border-top: 1px solid var(--border-soft); } +.goal-proposal-media > strong { color: var(--muted); font-size: 9.5px; letter-spacing: .06em; text-transform: uppercase; } +.goal-proposal-media > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 7px; } +.goal-proposal-media-card { overflow: hidden; background: var(--panel-2); border: 1px solid var(--border-soft); border-radius: 8px; } +.goal-proposal-media-card:hover { border-color: var(--accent-border); } +.goal-proposal-media-preview { display: block; width: 100%; height: 150px; background: var(--panel-3); object-fit: contain; } +.goal-proposal-media-preview img { display: block; width: 100%; height: 100%; object-fit: contain; } +.goal-proposal-media-info { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 3px 7px; padding: 9px; color: var(--text); text-decoration: none; border-top: 1px solid var(--border-soft); } +.goal-proposal-media-info > span { grid-row: 1 / 3; align-self: start; padding: 2px 5px; color: var(--accent); background: var(--accent-faint); border-radius: 4px; font-size: 8px; font-weight: 800; text-transform: uppercase; } +.goal-proposal-media-info > b { font-size: 11px; } +.goal-proposal-media-info > small { color: var(--muted); font-size: 10.5px; line-height: 1.4; } .goal-x-thread { display: grid; padding: 15px 18px 18px; } .goal-x-post { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; } .goal-x-post-rail { display: grid; grid-template-rows: 30px 1fr; justify-items: center; } @@ -191,6 +248,8 @@ .goal-x-post-body > small { display: block; color: var(--muted-2); font-size: 9.5px; text-align: right; } .goal-x-post-body > small.over-limit { color: #f85149; font-weight: 700; } @media (max-width: 560px) { + .content-source-compose { grid-template-columns: 1fr; } + .content-source-tabs button { min-height: 30px; flex: 1; justify-content: center; } .goal-proposal-head { grid-template-columns: auto minmax(0, 1fr); } .goal-proposal-head > .goal-proposal-copy { grid-column: 1 / -1; justify-self: end; } .goal-x-thread { padding-inline: 12px; } diff --git a/src/types/goals.ts b/src/types/goals.ts index 07cefb6..22d4d7b 100644 --- a/src/types/goals.ts +++ b/src/types/goals.ts @@ -12,6 +12,18 @@ export const GOAL_METRICS: readonly GoalMetric[] = GOAL_METRIC_DEFINITIONS.map(( export const GOAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post", "post", "issue", "discussion", "email", "checklist", "message", "doc"] as const; export type GoalProposalFormat = (typeof GOAL_PROPOSAL_FORMATS)[number]; +export type GoalContentSource = + | { type: "repository"; value: string } + | { type: "website"; value: string }; + +export interface GoalMediaSuggestion { + kind: "image" | "video"; + title: string; + /** A concrete asset URL, or the source page where it can be found. */ + sourceUrl: string; + guidance: string; +} + /** A ready-to-use deliverable that carries out one recommended action. */ export interface GoalProposal { title: string; @@ -21,6 +33,8 @@ export interface GoalProposal { content: string; /** Complete, ordered X posts. Present when format is `x-thread`. */ threadPosts?: string[]; + /** Visual assets that can accompany this platform-specific draft. */ + mediaSuggestions?: GoalMediaSuggestion[]; } export interface GoalSuggestion { diff --git a/src/utils/socialProposals.ts b/src/utils/socialProposals.ts index dcd19db..6f41d6b 100644 --- a/src/utils/socialProposals.ts +++ b/src/utils/socialProposals.ts @@ -1,4 +1,5 @@ -import type { GoalProposal, GoalProposalFormat } from "../types/goals"; +import type { GoalContentSource, GoalMediaSuggestion, GoalProposal, GoalProposalFormat } from "../types/goals"; +import { parseRepositoryName } from "./repository"; export const SOCIAL_PROPOSAL_FORMATS = ["x-thread", "linkedin-post", "mastodon-post"] as const satisfies readonly GoalProposalFormat[]; @@ -17,6 +18,133 @@ function hashtagCount(text: string): number { return [...text.matchAll(/(?:^|\s)#[\p{L}\p{N}_]+/gu)].length; } +function normalizeHttpUrl(value: string): string | null { + try { + const url = new URL(value.trim()); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +/** Validates, canonicalizes, and limits user-provided campaign sources. */ +export function normalizeContentSources(entries: unknown, limit = 6): GoalContentSource[] { + if (!Array.isArray(entries)) return []; + const sources: GoalContentSource[] = []; + const seen = new Set(); + for (const raw of entries) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const type = entry.type; + let value: string | null = null; + if (type === "repository") { + const parsed = parseRepositoryName(String(entry.value ?? "").trim()); + value = parsed ? `${parsed[0]}/${parsed[1]}` : null; + } + if (type === "website") value = normalizeHttpUrl(String(entry.value ?? "")); + if (!value) continue; + const key = `${type}:${value.toLocaleLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + sources.push({ type, value } as GoalContentSource); + if (sources.length >= limit) break; + } + return sources; +} + +export function extractMediaUrls(markdown: string, baseUrl?: string | null): string[] { + const candidates = [ + ...[...markdown.matchAll(/!\[[^\]]*\]\((?:<)?([^\s)>]+)(?:>)?(?:\s+["'][^"']*["'])?\)/g)].map((match) => match[1]), + ...[...markdown.matchAll(/<(?:img|video|source)\b[^>]*?\bsrc=["']([^"']+)["']/gi)].map((match) => match[1]), + ...[...markdown.matchAll(/]*?property=["'](?:og:image|og:video|twitter:image)["'][^>]*?content=["']([^"']+)["']/gi)].map((match) => match[1]), + ...[...markdown.matchAll(/]*?content=["']([^"']+)["'][^>]*?property=["'](?:og:image|og:video|twitter:image)["']/gi)].map((match) => match[1]), + ...[...markdown.matchAll(/\[[^\]]+\]\(([^\s)]+\.(?:mp4|webm|mov|gif)(?:\?[^\s)]*)?)\)/gi)].map((match) => match[1]), + ]; + const urls: string[] = []; + for (const candidate of candidates) { + try { + const resolved = new URL(candidate, baseUrl ?? undefined); + if ((resolved.protocol === "http:" || resolved.protocol === "https:") && !urls.includes(resolved.toString())) urls.push(resolved.toString()); + } catch { /* Ignore malformed and unresolved relative links. */ } + } + return urls.slice(0, 12); +} + +export interface WebPageSignal { + title: string | null; + excerpt: string; + mediaUrls: string[]; +} + +/** Extracts readable text and concrete media from a bounded HTML response. */ +export function extractWebPageSignal(html: string, pageUrl: string, excerptLength = 7_000): WebPageSignal { + const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); + const decode = (value: string) => value + .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) + .replace(/&#x([\da-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, "\"").replace(/'/gi, "'"); + const clean = (value: string) => decode(value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()); + const readable = html + .replace(/<(?:script|style|noscript|svg)\b[\s\S]*?<\/(?:script|style|noscript|svg)>/gi, " ") + .replace(//g, " "); + return { + title: titleMatch ? clean(titleMatch[1]) || null : null, + excerpt: clean(readable).slice(0, excerptLength), + mediaUrls: extractMediaUrls(html, pageUrl), + }; +} + +export function isVideoMediaUrl(value: string): boolean { + try { + return /\.(?:mp4|webm|mov|m4v)(?:$|\?)/i.test(new URL(value).pathname); + } catch { + return false; + } +} + +/** Ensures each post receives a concrete source asset, rotating the project library between posts. */ +export function attachSourceMedia(proposals: GoalProposal[], mediaUrls: string[]): GoalProposal[] { + const assets = [...new Set(mediaUrls.map(normalizeHttpUrl).filter((url): url is string => Boolean(url)))]; + if (!assets.length) return proposals.map((proposal) => ({ ...proposal, mediaSuggestions: [] })); + const allowed = new Set(assets); + return proposals.map((proposal, index) => { + const selected = (proposal.mediaSuggestions ?? []).filter((media) => allowed.has(media.sourceUrl)).slice(0, 2); + if (selected.length) return { ...proposal, mediaSuggestions: selected }; + const sourceUrl = assets[index % assets.length]; + const filename = decodeURIComponent(new URL(sourceUrl).pathname.split("/").pop() || "Source asset"); + return { + ...proposal, + mediaSuggestions: [{ + kind: isVideoMediaUrl(sourceUrl) ? "video" : "image", + title: filename, + sourceUrl, + guidance: "Attach this existing source asset to the post and verify reuse rights before publishing.", + }], + }; + }); +} + +function normalizeMediaSuggestions(value: unknown): GoalMediaSuggestion[] { + if (!Array.isArray(value)) return []; + const suggestions: GoalMediaSuggestion[] = []; + const seen = new Set(); + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const kind = entry.kind === "image" || entry.kind === "video" ? entry.kind : null; + const sourceUrl = normalizeHttpUrl(String(entry.sourceUrl ?? "")); + const title = String(entry.title ?? "").trim(); + const guidance = String(entry.guidance ?? "").trim(); + if (!kind || !sourceUrl || !title || !guidance || seen.has(sourceUrl)) continue; + seen.add(sourceUrl); + suggestions.push({ kind, sourceUrl, title, guidance }); + if (suggestions.length >= 3) break; + } + return suggestions; +} + export function socialProposalIssue(proposal: GoalProposal): string | null { if (!proposal.title.trim()) return "missing title"; if (!proposal.summary.trim()) return "missing audience and angle summary"; @@ -65,6 +193,7 @@ export function normalizeSocialProposals(entries: unknown): GoalProposal[] { summary: String(entry.summary ?? "").trim(), content, threadPosts: posts, + mediaSuggestions: normalizeMediaSuggestions(entry.mediaSuggestions), }; const fingerprint = content.toLocaleLowerCase(); if (seenFormats.has(format) || seenContent.has(fingerprint) || socialProposalIssue(proposal)) continue; diff --git a/tests/utils/socialProposals.test.ts b/tests/utils/socialProposals.test.ts index 713930b..fe77cc5 100644 --- a/tests/utils/socialProposals.test.ts +++ b/tests/utils/socialProposals.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { hasCompleteSocialSet, normalizeSocialProposals, socialCharacterCount } from "../../src/utils/socialProposals"; +import type { GoalProposal } from "../../src/types/goals"; +import { attachSourceMedia, extractMediaUrls, extractWebPageSignal, hasCompleteSocialSet, normalizeContentSources, normalizeSocialProposals, socialCharacterCount } from "../../src/utils/socialProposals"; describe("socialCharacterCount", () => { it("counts an emoji as one Unicode code point", () => { @@ -7,6 +8,57 @@ describe("socialCharacterCount", () => { }); }); +describe("normalizeContentSources", () => { + it("accepts repositories and HTTP websites while removing duplicates and unsafe URLs", () => { + expect(normalizeContentSources([ + { type: "repository", value: "owner/project" }, + { type: "repository", value: "owner/project" }, + { type: "website", value: "https://example.com/media#gallery" }, + { type: "website", value: "file:///etc/passwd" }, + ])).toEqual([ + { type: "repository", value: "owner/project" }, + { type: "website", value: "https://example.com/media" }, + ]); + }); +}); + +describe("extractMediaUrls", () => { + it("finds Markdown and HTML media and resolves relative URLs", () => { + expect(extractMediaUrls( + "![Demo](assets/demo.png)\n", + "https://raw.example/owner/repo/main/README.md", + )).toEqual([ + "https://raw.example/owner/repo/main/assets/demo.png", + "https://cdn.example/demo.mp4", + ]); + }); +}); + +describe("extractWebPageSignal", () => { + it("extracts readable source content and resolves page media", () => { + const result = extractWebPageSignal( + `Latest & greatest

Version 2

Faster builds.

`, + "https://example.com/releases/v2", + ); + expect(result.title).toBe("Latest & greatest"); + expect(result.excerpt).toContain("Version 2 Faster builds."); + expect(result.excerpt).not.toContain("ignore"); + expect(result.mediaUrls).toEqual(["https://example.com/cover.png"]); + }); +}); + +describe("attachSourceMedia", () => { + it("rotates concrete project assets between posts and removes invented media", () => { + const proposals = [ + { title: "X", format: "x-thread", summary: "s", content: "c", mediaSuggestions: [{ kind: "image", title: "Fake", sourceUrl: "https://fake.test/x.png", guidance: "g" }] }, + { title: "LinkedIn", format: "linkedin-post", summary: "s", content: "c" }, + ] as GoalProposal[]; + const result = attachSourceMedia(proposals, ["https://source.test/a.png", "https://source.test/demo.mp4"]); + expect(result[0].mediaSuggestions?.[0].sourceUrl).toBe("https://source.test/a.png"); + expect(result[1].mediaSuggestions?.[0]).toMatchObject({ kind: "video", sourceUrl: "https://source.test/demo.mp4" }); + }); +}); + describe("normalizeSocialProposals", () => { const threadPosts = ["Hook", "Problem", "Approach", "Evidence", "Call to action"]; @@ -14,10 +66,14 @@ describe("normalizeSocialProposals", () => { const result = normalizeSocialProposals([ { title: "X", format: "x-thread", summary: "Developers; README angle.", content: "wrong", threadPosts }, { title: "LinkedIn", format: "linkedin-post", summary: "Technical leaders; project value.", content: "A professional post.", threadPosts: [] }, - { title: "Mastodon", format: "mastodon-post", summary: "OSS community; contribution angle.", content: "A community post.", threadPosts: [] }, + { title: "Mastodon", format: "mastodon-post", summary: "OSS community; contribution angle.", content: "A community post.", threadPosts: [], mediaSuggestions: [ + { kind: "image", title: "Demo", sourceUrl: "https://example.com/demo.png", guidance: "Use the existing screenshot." }, + { kind: "audio", title: "Invalid", sourceUrl: "file:///demo", guidance: "No." }, + ] }, ]); expect(result).toHaveLength(3); + expect(result[2].mediaSuggestions).toEqual([{ kind: "image", title: "Demo", sourceUrl: "https://example.com/demo.png", guidance: "Use the existing screenshot." }]); expect(result[0].content).toBe(threadPosts.join("\n\n---\n\n")); expect(hasCompleteSocialSet(result)).toBe(true); });