Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ CodePlans sits between your issue tracker and your architecture diagram:
| Asset dependency mapping & plan impact analysis | ✅ Available |
| Analytics wired to real data (velocity, effort accuracy, debt by product) | ✅ Available |
| Activity feed | ✅ Available |
| GitHub & GitLab Issues integrations (pull-only mirror into work items) | ✅ Available |
| GitHub, GitLab, Jira, Asana & Linear integrations (pull-only mirror into work items) | ✅ Available |
| MCP server — 28 tools incl. product/asset/dependency management & email-based task assignment | ✅ Available |
| Jira / Asana / Linear connectors | 🔜 Planned |
| Milestone-linked plans with mirrored tasks (mixed mode) | ✅ Available |
| PR auto-linking (plan-asset PR status refreshed on sync) | ✅ Available |
| AI-assisted effort estimation | 🔜 Planned |
Expand Down
25 changes: 25 additions & 0 deletions app/(dashboard)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { db } from '@/lib/db'
import { users, organizationMembers, organizations, emailVerificationTokens, workItems, syncLog } from '@/lib/db/schema'
import { eq, and, gt } from 'drizzle-orm'
import {
updateIntegration,
createProduct,
updateProduct,
deleteProduct,
Expand Down Expand Up @@ -793,6 +794,30 @@ export async function createIntegrationAction(formData: FormData) {
return {}
}

export async function updateIntegrationAction(id: string, formData: FormData) {
const authUser = await requireUser()
const profile = await getUserProfile(authUser.id)
if (!profile?.organizationId) return { error: 'No workspace found.' }

const name = formData.get('name') as string
const repo = (formData.get('repo') as string) || undefined
const baseUrl = (formData.get('baseUrl') as string) || undefined
const authRef = (formData.get('authRef') as string) || undefined
const token = (formData.get('token') as string)?.trim() || undefined
const productId = (formData.get('productId') as string) || undefined
if (!productId) return { error: 'Select a target product for mirrored items.' }

await updateIntegration(id, {
name,
// token undefined keeps the stored credential; authRef only set when provided
token,
...(authRef !== undefined ? { authRef } : {}),
config: { repo, baseUrl, productId },
})
revalidatePath('/integrations')
return { ok: true as const }
}

export async function deleteIntegrationAction(id: string) {
await requireUser()
await deleteIntegration(id)
Expand Down
147 changes: 139 additions & 8 deletions app/(dashboard)/integrations/integrations-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Github, Gitlab, Plus, RefreshCw, Trash2, AlertCircle, Plug } from 'lucide-react'
import { Github, Gitlab, Plus, RefreshCw, Trash2, AlertCircle, Plug, Ticket, ListTodo, Zap, Pencil } from 'lucide-react'
import type { IntegrationSummary } from '@/lib/db/queries'
import { cn, formatDateShort } from '@/lib/utils'
import { createIntegrationAction, deleteIntegrationAction, syncIntegrationAction } from '../actions'
import { createIntegrationAction, updateIntegrationAction, deleteIntegrationAction, syncIntegrationAction } from '../actions'

type ProductOption = { id: string; name: string }

Expand All @@ -40,7 +40,19 @@ const statusStyles: Record<string, string> = {
error: 'bg-destructive/20 text-destructive',
}

const PROVIDERS = [
type ProviderMeta = {
value: string
label: string
icon: typeof Github
repoLabel: string
repoPlaceholder: string
tokenHint: string
hasBaseUrl: boolean
baseUrlLabel?: string
baseUrlRequired?: boolean
}

const PROVIDERS: readonly ProviderMeta[] = [
{
value: 'github',
label: 'GitHub Issues',
Expand All @@ -49,7 +61,7 @@ const PROVIDERS = [
repoPlaceholder: 'owner/repo',
tokenHint: 'GitHub token with repo read access',
hasBaseUrl: false,
},
} as ProviderMeta,
{
value: 'gitlab',
label: 'GitLab Issues',
Expand All @@ -58,8 +70,39 @@ const PROVIDERS = [
repoPlaceholder: 'group/project',
tokenHint: 'GitLab token with read_api scope',
hasBaseUrl: true,
baseUrlLabel: 'Instance URL',
baseUrlRequired: false,
},
{
value: 'jira',
label: 'Jira',
icon: Ticket,
repoLabel: 'Project key',
repoPlaceholder: 'ENG',
tokenHint: 'Jira credential as email:api_token',
hasBaseUrl: true,
baseUrlLabel: 'Site URL',
baseUrlRequired: true,
},
{
value: 'asana',
label: 'Asana',
icon: ListTodo,
repoLabel: 'Project GID',
repoPlaceholder: 'e.g. 1203456789012345',
tokenHint: 'Asana Personal Access Token',
hasBaseUrl: false,
},
{
value: 'linear',
label: 'Linear',
icon: Zap,
repoLabel: 'Team key',
repoPlaceholder: 'ENG',
tokenHint: 'Linear API key',
hasBaseUrl: false,
},
] as const
]

function providerMeta(provider: string) {
return PROVIDERS.find((p) => p.value === provider) ?? PROVIDERS[0]
Expand Down Expand Up @@ -172,6 +215,7 @@ export function IntegrationsClient({
<Badge variant="secondary" className={cn('text-xs capitalize', statusStyles[conn.status])}>
{conn.status}
</Badge>
<EditConnectionDialog conn={conn} products={products} />
<Button
variant="outline"
size="sm"
Expand Down Expand Up @@ -222,6 +266,85 @@ export function IntegrationsClient({
)
}

function EditConnectionDialog({ conn, products }: { conn: IntegrationSummary; products: ProductOption[] }) {
const [open, setOpen] = useState(false)
const [productId, setProductId] = useState((conn.config.productId as string) ?? products[0]?.id ?? '')
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const meta = providerMeta(conn.provider)

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError(null)
const fd = new FormData(e.currentTarget)
fd.set('productId', productId)
startTransition(async () => {
const result = await updateIntegrationAction(conn.id, fd)
if (result?.error) setError(result.error)
else setOpen(false)
})
}

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" title="Edit connection">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit connection</DialogTitle>
<DialogDescription>{meta.label} — leave the token blank to keep the current credential.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ec-name">Connection name <span className="text-destructive">*</span></Label>
<Input id="ec-name" name="name" defaultValue={conn.name} required />
</div>
<div className="space-y-2">
<Label htmlFor="ec-repo">{meta.repoLabel} <span className="text-destructive">*</span></Label>
<Input id="ec-repo" name="repo" defaultValue={(conn.config.repo as string) ?? ''} placeholder={meta.repoPlaceholder} required />
</div>
{meta.hasBaseUrl && (
<div className="space-y-2">
<Label htmlFor="ec-baseurl">{meta.baseUrlLabel ?? 'Instance URL'}</Label>
<Input id="ec-baseurl" name="baseUrl" type="url" defaultValue={(conn.config.baseUrl as string) ?? ''} required={meta.baseUrlRequired} />
</div>
)}
<div className="space-y-2">
<Label htmlFor="ec-product">Target product <span className="text-destructive">*</span></Label>
<Select value={productId} onValueChange={setProductId}>
<SelectTrigger id="ec-product"><SelectValue placeholder="Select a product" /></SelectTrigger>
<SelectContent>
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="ec-token">
Replace access token
<span className="ml-1 text-xs text-muted-foreground">(blank = keep current)</span>
</Label>
<Input id="ec-token" name="token" type="password" placeholder={`Paste a ${meta.tokenHint}`} autoComplete="off" />
</div>
{conn.authRef && (
<div className="space-y-2">
<Label htmlFor="ec-authref">Env-var name</Label>
<Input id="ec-authref" name="authRef" defaultValue={conn.authRef} />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={isPending || !productId}>{isPending ? 'Saving…' : 'Save'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

function NewConnectionDialog({
products,
trigger,
Expand Down Expand Up @@ -295,10 +418,18 @@ function NewConnectionDialog({
{meta.hasBaseUrl && (
<div className="space-y-2">
<Label htmlFor="ic-baseurl">
Instance URL
<span className="ml-1 text-xs text-muted-foreground">(self-hosted only, optional)</span>
{meta.baseUrlLabel ?? 'Instance URL'}
{meta.baseUrlRequired
? <span className="text-destructive"> *</span>
: <span className="ml-1 text-xs text-muted-foreground">(self-hosted only, optional)</span>}
</Label>
<Input id="ic-baseurl" name="baseUrl" type="url" placeholder="https://gitlab.example.com" />
<Input
id="ic-baseurl"
name="baseUrl"
type="url"
placeholder={meta.value === 'jira' ? 'https://your-site.atlassian.net' : 'https://gitlab.example.com'}
required={meta.baseUrlRequired}
/>
</div>
)}
<div className="space-y-2">
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/plans/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default async function PlansPage({ searchParams }: Props) {
<PlanCreatePanel products={productList} defaultProductId={productId} />
</div>

<PlansClient plans={enrichedPlans} products={productList} />
<PlansClient plans={enrichedPlans} products={productList} currentUserId={user.id} />
</div>
)
}
12 changes: 11 additions & 1 deletion app/(dashboard)/plans/plans-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { cn, formatDate } from '@/lib/utils'
import { PlanCreatePanel } from './plan-create-panel'

type Plan = {
ownerId?: string
id: string
title: string
description: string
Expand Down Expand Up @@ -52,11 +53,13 @@ const typeStyles: Record<CodePlanType, string> = {
bugfix: 'bg-chart-5/20 text-chart-5',
}

export function PlansClient({ plans, products }: { plans: Plan[]; products: Product[] }) {
export function PlansClient({ plans, products, currentUserId }: { plans: Plan[]; products: Product[]; currentUserId?: string }) {
const [mineOnly, setMineOnly] = useState(false)
const [statusFilter, setStatusFilter] = useState<CodePlanStatus | 'all' | 'open'>('open')
const [productFilter, setProductFilter] = useState<string>('all')

const filteredPlans = plans.filter((plan) => {
if (mineOnly && plan.ownerId !== currentUserId && !plan.assigneeIds.includes(currentUserId ?? '')) return false
if (statusFilter === 'open') {
if (plan.status === 'completed' || plan.status === 'cancelled') return false
} else if (statusFilter !== 'all' && plan.status !== statusFilter) return false
Expand Down Expand Up @@ -128,6 +131,13 @@ export function PlansClient({ plans, products }: { plans: Plan[]; products: Prod
<TabsTrigger value="completed">Completed</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant={mineOnly ? 'secondary' : 'outline'}
size="sm"
onClick={() => setMineOnly((v) => !v)}
>
My plans
</Button>
<div className="flex items-center gap-2 sm:ml-auto">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select value={productFilter} onValueChange={setProductFilter}>
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/tasks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default async function TasksPage() {

return (
<div className="space-y-8">
<TasksClient tasks={tasks} plans={planList} members={memberList} />
<TasksClient tasks={tasks} plans={planList} members={memberList} currentUserId={user.id} />
</div>
)
}
11 changes: 11 additions & 0 deletions app/(dashboard)/tasks/tasks-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,14 @@ export function TasksClient({
tasks,
plans,
members,
currentUserId,
}: {
tasks: TaskRow[]
plans: PlanOption[]
members: MemberOption[]
currentUserId?: string
}) {
const [mineOnly, setMineOnly] = useState(false)
const searchParams = useSearchParams()
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all' | 'open'>('open')
const [planFilter, setPlanFilter] = useState<string>('all')
Expand All @@ -196,6 +199,7 @@ export function TasksClient({
}, [openTaskId])

const filteredTasks = tasks.filter((task) => {
if (mineOnly && task.assigneeId !== currentUserId) return false
if (statusFilter === 'open') {
if (task.status === 'done') return false
} else if (statusFilter !== 'all' && task.status !== statusFilter) return false
Expand Down Expand Up @@ -281,6 +285,13 @@ export function TasksClient({
<TabsTrigger value="done">Done</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant={mineOnly ? 'secondary' : 'outline'}
size="sm"
onClick={() => { setMineOnly((v) => !v); setPage(0) }}
>
Assigned to me
</Button>
<div className="flex items-center gap-2 sm:ml-auto">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select value={planFilter} onValueChange={(v) => { setPlanFilter(v); setPage(0) }}>
Expand Down
2 changes: 1 addition & 1 deletion docs/app-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ Client component (`WorkItemsClient`) with:
Client component (`IntegrationsClient`) with:
- Connection cards: provider icon, name, repo, mirrored count, last sync, status badge, surfaced `lastError`
- "Sync now" → `syncIntegrationAction` (runs the pull-only sync engine; shows created/updated/unchanged)
- "New Connection" dialog: provider select (GitHub Issues / GitLab Issues), name, repo/project path, instance URL (GitLab self-hosted, optional), target product, and the credential — paste a token (stored encrypted) or name a server env var → `createIntegrationAction`. Cards show credential status (token stored / env ✓ / env missing ⚠)
- "New Connection" dialog: provider select (GitHub / GitLab / Jira / Asana / Linear), name, provider scope (repo, project path, Jira project key + site URL, Asana project GID, Linear team key), target product, and the credential — paste a token (stored encrypted) or name a server env var → `createIntegrationAction`. Cards show credential status (token stored / env ✓ / env missing ⚠) and an edit dialog (rename, re-scope, re-target, replace token — blank keeps current)
- Delete with confirm (mirrored items are kept, stop syncing)

#### `/api/mcp/[transport]` — MCP server (no UI)
Expand Down
19 changes: 19 additions & 0 deletions lib/db/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,25 @@ export async function createIntegration(data: CreateIntegrationData) {
return row
}

type UpdateIntegrationData = {
name?: string
authRef?: string | null
/** New token to encrypt and store; undefined = keep existing credential. */
token?: string
config?: Record<string, unknown>
}

export async function updateIntegration(id: string, data: UpdateIntegrationData) {
const { token, ...columns } = data
const patch: Record<string, unknown> = { ...columns }
if (token) {
const { encryptToken } = await import('@/lib/integrations/secrets')
patch.tokenEncrypted = encryptToken(token)
}
const [row] = await db.update(integrations).set(patch).where(eq(integrations.id, id)).returning()
return row ?? null
}

export async function deleteIntegration(id: string) {
const [deleted] = await db
.delete(integrations)
Expand Down
Loading
Loading