diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea78..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..fd7b3fa6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,156 @@ +name: Bug report +description: Report a defect in Devlane so we can fix it. +title: "[BUG] " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please fill in as much detail as you can — it helps us reproduce and fix the problem faster. + + **Do not report security vulnerabilities here.** See [SECURITY.md](../SECURITY.md) for private disclosure. + + - type: checkboxes + id: prechecks + attributes: + label: Pre-submission checks + options: + - label: I have searched existing issues and this is not a duplicate. + required: true + - label: I am using the latest `main` or the most recent release. + required: true + - label: This is not a security vulnerability (those go to SECURITY.md). + required: true + + - type: dropdown + id: component + attributes: + label: Affected component + options: + - API (Go / Gin backend) + - UI (React SPA) + - Editor (TipTap — description / comments) + - Auth (sessions, magic-code, OAuth providers) + - Workspaces / Projects / Issues + - Modules / Cycles / Views + - Activity feed / Notifications + - Integrations (GitHub, etc.) + - Database / Migrations + - Background jobs (RabbitMQ / queue) + - File uploads (MinIO) + - Email / SMTP + - Instance Admin + - Docker / Deployment + - Documentation + - Other + validations: + required: true + + - type: dropdown + id: severity + attributes: + label: Severity + description: How badly does this affect users? + options: + - Critical — data loss, security exposure, or full outage + - High — core flow broken, no workaround + - Medium — feature broken with a workaround + - Low — cosmetic, edge case, or minor UX issue + validations: + required: true + + - type: textarea + id: summary + attributes: + label: Summary + description: A clear, concise description of the bug. + placeholder: When I drag an issue between board columns, the state badge doesn't update until I refresh. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Minimal, numbered steps. Include sample data or a workspace/project slug if possible. + value: | + 1. Sign in to workspace `…` + 2. Open project `…` + 3. … + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + + - type: input + id: version + attributes: + label: Devlane version or commit + description: Release tag (e.g. v0.5.1) or git commit SHA from `git rev-parse --short HEAD`. + placeholder: v0.5.1 or abc1234 + validations: + required: true + + - type: input + id: environment + attributes: + label: Environment + description: OS, browser (UI bugs), Go version (API bugs), Node version. + placeholder: Windows 11, Chrome 132, Go 1.23.4, Node 22.11.0 + validations: + required: true + + - type: dropdown + id: deployment + attributes: + label: Deployment mode + options: + - Local development (`docker compose up` + `npm run dev` + `go run ./cmd/api`) + - Self-hosted production + - Other + validations: + required: true + + - type: dropdown + id: db + attributes: + label: Database state + description: Is your local DB freshly migrated, or could there be schema drift? + options: + - Fresh — migrations applied cleanly on startup + - Existing — could have drift from older migrations + - Not applicable + validations: + required: false + + - type: textarea + id: api_logs + attributes: + label: API logs + description: Relevant lines from `go run ./cmd/api` output. Redact session cookies / tokens. + render: shell + + - type: textarea + id: console_logs + attributes: + label: Browser console / network output + description: Paste console errors or failed request responses. Redact bearer tokens. + render: shell + + - type: textarea + id: additional + attributes: + label: Additional context + description: Screenshots, screen recordings, related issues, or anything else that helps reproduce. diff --git a/.github/ISSUE_TEMPLATE/chore.yml b/.github/ISSUE_TEMPLATE/chore.yml new file mode 100644 index 00000000..55e3bd5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.yml @@ -0,0 +1,62 @@ +name: Chore / tech debt +description: Track maintenance, refactor, dependency upgrade, or cleanup work. +title: "[CHORE] " +labels: ["chore", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Use this for work that doesn't add user-visible features and isn't a bug report — e.g. dependency upgrades, refactors, dead-code removal, CI tuning, or planned tech debt cleanup. + + - type: dropdown + id: kind + attributes: + label: Kind + options: + - Refactor (no behavior change) + - Dependency upgrade + - Build / CI / tooling + - Test coverage + - Performance improvement + - Dead code / cleanup + - Migration follow-up + - Other + validations: + required: true + + - type: dropdown + id: scope + attributes: + label: Scope + options: + - API + - UI + - Infra / Docker + - Repo-wide + validations: + required: true + + - type: textarea + id: motivation + attributes: + label: Motivation + description: Why is this worth doing now? What does ignoring it cost us? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed approach + description: How should this be tackled? Files / modules involved, rough plan. + + - type: textarea + id: risk + attributes: + label: Risk and rollback + description: What could regress? How would we revert if it does? + + - type: textarea + id: additional + attributes: + label: Additional context diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..428c0685 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/Devlaner/devlane/security/advisories/new + about: Privately disclose a security issue. Do not file a public bug report. + - name: Question or general discussion + url: https://github.com/Devlaner/devlane/discussions + about: Ask questions, share usage tips, or kick around an idea before opening an issue. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 00000000..9f699e46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,57 @@ +name: Documentation +description: Report missing, incorrect, or unclear documentation. +title: "[DOCS] " +labels: ["documentation", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Use this template for problems with `README`, `CLAUDE.md`, in-code comments that mislead, the `planning/` design docs, or any onboarding gap. + + - type: checkboxes + id: prechecks + attributes: + label: Pre-submission checks + options: + - label: I have searched existing docs issues and this is not a duplicate. + required: true + + - type: dropdown + id: kind + attributes: + label: Issue kind + options: + - Missing — something should be documented but isn't + - Incorrect — documented behavior doesn't match reality + - Unclear — accurate but hard to follow + - Outdated — was correct, isn't anymore + - Broken link / formatting + validations: + required: true + + - type: input + id: location + attributes: + label: Location + description: File path, doc URL, or section. + placeholder: README.md, planning/backend-architecture.md, code comment in api/internal/router/router.go + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What's wrong or missing? + validations: + required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested wording or structure + description: Optional — if you have a concrete suggestion, paste it here. + + - type: textarea + id: additional + attributes: + label: Additional context diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..ccd3f1ca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,117 @@ +name: Feature request +description: Propose a new feature or an improvement to an existing one. +title: "[FEAT] " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Please describe the problem first, then the proposed solution. Concrete examples are much easier to evaluate than abstract requests. + + For very large features, consider opening a [Discussion](../../discussions) first so we can shape scope together before tracking it as an issue. + + - type: checkboxes + id: prechecks + attributes: + label: Pre-submission checks + options: + - label: I have searched existing issues / discussions and this is not a duplicate. + required: true + - label: This is a feature request, not a bug report. + required: true + + - type: dropdown + id: scope + attributes: + label: Scope + description: Which surface(s) does this affect? + options: + - UI only + - API only + - UI + API + - DB schema (requires migration) + - Infra / Docker / Deployment + - Documentation + - Cross-cutting (multiple of the above) + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Primary area + options: + - Workspaces / Projects / Issues + - Modules / Cycles / Views + - Editor (description / comments) + - Activity feed / Notifications + - Integrations (GitHub, future Slack/Linear/Jira) + - Auth (sessions, magic-code, OAuth) + - Search / Filters + - Instance Admin + - Performance / Scalability + - Accessibility + - Theming / Design system + - Background jobs (RabbitMQ / queue) + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What pain point or limitation does this address? Who hits it, and how often? + placeholder: When I open an issue with 50+ comments the page takes ~3s to render and the editor steals focus on every reload. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the desired behavior. Include API shape / UI mock-ups when useful. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches you ruled out, and why. + + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: How will we know this is done? + value: | + - [ ] … + - [ ] … + - [ ] … + + - type: dropdown + id: priority + attributes: + label: Priority (your view) + description: Maintainers may re-prioritize during triage. + options: + - Nice to have + - Should have + - Must have for next release + validations: + required: false + + - type: checkboxes + id: contribution + attributes: + label: Contribution + options: + - label: I'm willing to open a PR for this myself. + - label: I can help test once a PR is opened. + + - type: textarea + id: additional + attributes: + label: Additional context + description: Screenshots, mock-ups, links to similar features in other tools, related issues. diff --git a/.github/PULL_REQUEST_TEMPLATE/bugfix.md b/.github/PULL_REQUEST_TEMPLATE/bugfix.md new file mode 100644 index 00000000..6512964c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bugfix.md @@ -0,0 +1,62 @@ + + +## What was broken + + + +## Linked issues + +Fixes # + +## Root cause + + + +## The fix + + + +## Why this fix is correct + + + +## Reproduction + + +1. +2. +3. + +## Test plan + +- [ ] `npm run validate` green +- [ ] Reproduction above no longer triggers the failure +- [ ] Added a regression test that fails on `main` and passes on this branch +- [ ] Manually verified neighboring flows that share the affected code path + +## Regression risk + + + +## Screenshots / logs (if applicable) + +| Before (broken) | After (fixed) | +| --------------- | ------------- | +| | | + +## Checklist + +- [ ] PR title is `fix(): …` and ≤ 100 chars +- [ ] Regression test added (or explained why one isn't possible) +- [ ] No `--no-verify` bypass diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md index 97e4a31c..fad16a5d 100644 --- a/.github/PULL_REQUEST_TEMPLATE/feature.md +++ b/.github/PULL_REQUEST_TEMPLATE/feature.md @@ -1,64 +1,75 @@ -## 🚀 Feature + -### Description +## Feature summary -Provide a clear and concise description of the feature introduced in this pull request. + -### Motivation +## Linked issues / discussion -Why is this feature needed? -What problem does it solve? +Closes # + -### Changes +## User-facing behavior -List the main changes introduced: + -* Added ... -* Implemented ... -* Updated ... +## What changed -### Usage Example +### API (`api/`) + +- -Show how the new feature can be used. +### UI (`ui/`) + +- -```bash -# example command -example_command --flag -``` +### Database + +- -or +## Why this design -```javascript -// example usage -exampleFunction() -``` + -### Screenshots / Demo (optional) +## Test plan -If applicable, include screenshots, logs, or demo output. +- [ ] `npm run validate` green +- [ ] Manual end-to-end walkthrough: + 1. + 2. + 3. +- [ ] Tested with both light and dark theme (if UI) +- [ ] Tested at narrow viewport (if UI) +- [ ] Re-running the migration on a fresh DB passes (if migration) -### Breaking Changes +## Screenshots / recording -Does this change break existing functionality? + -* [ ] No -* [ ] Yes (describe below) +| Before | After | +| ------ | ----- | +| | | -Description of breaking change (if any): +## Out of scope (follow-ups) ---- + +- -## ✅ Checklist +## Rollout notes -* [ ] Code compiles/builds successfully -* [ ] Code is formatted -* [ ] Linting passes -* [ ] Documentation updated -* [ ] Tests added or updated (if applicable) -* [ ] No breaking changes introduced + ---- +## Checklist -## 📎 Additional Notes - -Add any extra context, implementation details, or references here. +- [ ] PR title follows Conventional Commits and is ≤ 100 chars +- [ ] Trailing slashes on new routes match neighboring routes +- [ ] New env vars documented in `internal/config/config.go` +- [ ] New instance settings reachable from the admin UI +- [ ] No `--no-verify` bypass +- [ ] Acceptance criteria from the linked issue are all met diff --git a/.github/PULL_REQUEST_TEMPLATE/refactor.md b/.github/PULL_REQUEST_TEMPLATE/refactor.md new file mode 100644 index 00000000..a6d86edd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/refactor.md @@ -0,0 +1,67 @@ + + +## What's being refactored + + + +## Linked issues / context + + + +## Why now + + + +## Approach + + +- +- + +## Behavior preservation + + +- [ ] Existing tests still pass without modification +- [ ] No public API surface changed (URLs, request/response shapes, exported types) +- [ ] No DB schema change +- [ ] No new env vars / instance settings +- [ ] If any tests were renamed/moved, semantics are identical + +## Out of scope + + +- + +## Test plan + +- [ ] `npm run validate` green +- [ ] Manually exercised the affected flow end-to-end +- [ ] Spot-checked one or two neighboring flows that share the touched code + +## Risk and rollback + + + +## Checklist + +- [ ] PR title is `refactor(): …` and ≤ 100 chars +- [ ] Truly no behavior change (otherwise switch to `feature.md` / `bugfix.md`) +- [ ] No `--no-verify` bypass +- [ ] No half-finished migrations or dead code introduced diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 115b97f9..0f412b93 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,20 +1,96 @@ -### Description - - -### Type of Change - -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] Feature (non-breaking change which adds functionality) -- [ ] Improvement (change that would cause existing functionality to not work as expected) -- [ ] Code refactoring -- [ ] Performance improvements -- [ ] Documentation update - -### Screenshots and Media (if applicable) - - -### Test Scenarios - - -### References - + + +## Summary + + + +## Linked issues + + +Closes # + +## Type of change + + +- [ ] Feature (`feat:`) — user-visible new capability +- [ ] Bug fix (`fix:`) — corrects broken behavior +- [ ] Refactor (`refactor:`) — no behavior change, internal only +- [ ] Performance (`perf:`) — measurable improvement +- [ ] Documentation (`docs:`) — README / CLAUDE.md / planning docs only +- [ ] Tests (`test:`) — adds or corrects tests +- [ ] Chore (`chore:`) — deps, tooling, CI, formatting +- [ ] Style (`style:`) — visual / theming polish only + +## Surface + + +- [ ] API (`api/`) +- [ ] UI (`ui/`) +- [ ] Database migration (`api/migrations/`) +- [ ] Background jobs (RabbitMQ / queue) +- [ ] Instance settings / Admin UI +- [ ] Infra / Docker / CI + +## What changed + + +- +- + +## Why this approach + + + +## Database / migrations + + +- [ ] Added `api/migrations/NNNNNN_.up.sql` AND matching `.down.sql` +- [ ] Migration is idempotent / safe to re-run on a fresh DB +- [ ] Migration applied cleanly via `database.RunMigrations` on startup + +## Breaking changes + +- [ ] No +- [ ] Yes — described below + + + +## Test plan + + +- [ ] `npm run validate` (root) — typecheck + lint + prettier + go vet + go test +- [ ] Manual smoke test of the affected flow: + - + +## Screenshots / recordings (UI changes) + + + +| Before | After | +| ------ | ----- | +| | | + +## Rollout notes + + + +## Checklist + +- [ ] PR title follows Conventional Commits and is ≤ 100 chars +- [ ] Hooks ran cleanly (no `--no-verify` bypass) +- [ ] Trailing slashes on new routes match the surrounding pattern +- [ ] New env vars added to `internal/config/config.go` and documented above +- [ ] No secrets, tokens, or `.env` values committed diff --git a/.gitignore b/.gitignore index 9061adb0..9101cd43 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules/ .cursor .vscode .idea +local/ +CLAUDE.md .DS_Store .env .env.local diff --git a/api/internal/github/app.go b/api/internal/github/app.go new file mode 100644 index 00000000..827c75f1 --- /dev/null +++ b/api/internal/github/app.go @@ -0,0 +1,90 @@ +// Package github provides GitHub App authentication and HTTP client helpers +// for the Devlane GitHub integration. It is deliberately self-contained — no +// dependency on services or stores — so it can be reused for tests. +package github + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "strings" + "time" +) + +// AppAuth holds a GitHub App's identity (ID + private key) and produces +// short-lived JWTs that GitHub accepts as the App's bearer token. +type AppAuth struct { + AppID int64 + PrivateKey *rsa.PrivateKey +} + +// NewAppAuth parses a PEM-encoded RSA private key and returns an AppAuth. +// The PEM may be PKCS#1 ("-----BEGIN RSA PRIVATE KEY-----") or PKCS#8 +// ("-----BEGIN PRIVATE KEY-----"); GitHub serves PKCS#1 by default. +func NewAppAuth(appID int64, privateKeyPEM string) (*AppAuth, error) { + if appID <= 0 { + return nil, errors.New("github: app id must be > 0") + } + pem := strings.TrimSpace(privateKeyPEM) + if pem == "" { + return nil, errors.New("github: private key is empty") + } + key, err := parseRSAPrivateKey(pem) + if err != nil { + return nil, err + } + return &AppAuth{AppID: appID, PrivateKey: key}, nil +} + +func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, errors.New("github: invalid PEM block") + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + if k8, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + if k, ok := k8.(*rsa.PrivateKey); ok { + return k, nil + } + return nil, errors.New("github: PKCS#8 key is not RSA") + } + return nil, errors.New("github: unsupported private key format (expected RSA PKCS#1 or PKCS#8)") +} + +// JWT returns a short-lived (10-minute) JWT signed with the App's private key. +// Use this only to call /app/* endpoints or to exchange for an installation +// token — never to call repo APIs directly. +func (a *AppAuth) JWT(now time.Time) (string, error) { + if a == nil || a.PrivateKey == nil { + return "", errors.New("github: app auth is not configured") + } + header := map[string]string{"alg": "RS256", "typ": "JWT"} + // GitHub recommends iat backdated by 60s to allow for clock drift, exp <= 10m. + claims := map[string]interface{}{ + "iat": now.Add(-60 * time.Second).Unix(), + "exp": now.Add(9 * time.Minute).Unix(), + "iss": a.AppID, + } + hb, _ := json.Marshal(header) + cb, _ := json.Marshal(claims) + signing := base64URLEncode(hb) + "." + base64URLEncode(cb) + hash := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, a.PrivateKey, crypto.SHA256, hash[:]) + if err != nil { + return "", fmt.Errorf("github: sign JWT: %w", err) + } + return signing + "." + base64URLEncode(sig), nil +} + +func base64URLEncode(b []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(b), "=") +} diff --git a/api/internal/github/client.go b/api/internal/github/client.go new file mode 100644 index 00000000..d7ee9c92 --- /dev/null +++ b/api/internal/github/client.go @@ -0,0 +1,206 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "time" +) + +const ( + defaultAPIBase = "https://api.github.com" + defaultUserAgent = "Devlane/1.0 (+https://github.com/Devlaner/devlane)" +) + +// Repository is a trimmed-down GitHub repository payload (only fields we use). +type Repository struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` + Owner struct { + Login string `json:"login"` + ID int64 `json:"id"` + Type string `json:"type"` + AvatarURL string `json:"avatar_url"` + } `json:"owner"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + UpdatedAt time.Time `json:"updated_at"` +} + +// installationToken is the response from POST /app/installations/:id/access_tokens. +type installationToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// cachedToken is what we keep in memory; we refresh ~5 min before expiry. +type cachedToken struct { + token string + expiresAt time.Time +} + +// Client is an HTTP wrapper around the GitHub REST API authenticated as a +// specific App installation. Tokens are cached in-memory per-installation and +// refreshed lazily. +type Client struct { + app *AppAuth + httpClient *http.Client + apiBase string + + mu sync.Mutex + tokens map[int64]cachedToken +} + +// NewClient builds a Client for the given AppAuth. Pass nil for httpClient to +// use http.DefaultClient. +func NewClient(app *AppAuth, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &Client{ + app: app, + httpClient: httpClient, + apiBase: defaultAPIBase, + tokens: make(map[int64]cachedToken), + } +} + +// SetAPIBase overrides the base URL (useful for tests or GitHub Enterprise). +func (c *Client) SetAPIBase(base string) { c.apiBase = base } + +// InstallationToken returns a fresh installation token, using the cache when possible. +func (c *Client) InstallationToken(ctx context.Context, installationID int64) (string, error) { + c.mu.Lock() + if t, ok := c.tokens[installationID]; ok && time.Until(t.expiresAt) > 5*time.Minute { + c.mu.Unlock() + return t.token, nil + } + c.mu.Unlock() + + jwt, err := c.app.JWT(time.Now().UTC()) + if err != nil { + return "", err + } + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", c.apiBase, installationID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("github: installation token (status %d): %s", resp.StatusCode, string(body)) + } + var tk installationToken + if err := json.NewDecoder(resp.Body).Decode(&tk); err != nil { + return "", err + } + c.mu.Lock() + c.tokens[installationID] = cachedToken{token: tk.Token, expiresAt: tk.ExpiresAt} + c.mu.Unlock() + return tk.Token, nil +} + +// InvalidateInstallation drops a cached token (call on suspend / uninstall). +func (c *Client) InvalidateInstallation(installationID int64) { + c.mu.Lock() + delete(c.tokens, installationID) + c.mu.Unlock() +} + +// doInstallation performs an authenticated request as the installation. +// page == 0 / perPage == 0 means "no pagination params". +func (c *Client) doInstallation(ctx context.Context, method, url string, installationID int64, body any) (*http.Response, error) { + token, err := c.InstallationToken(ctx, installationID) + if err != nil { + return nil, err + } + var rdr io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, method, url, rdr) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if rdr != nil { + req.Header.Set("Content-Type", "application/json") + } + return c.httpClient.Do(req) +} + +// ListInstallationRepositories fetches one page of an installation's accessible repos. +// Page is 1-based; perPage caps at 100. +func (c *Client) ListInstallationRepositories(ctx context.Context, installationID int64, page, perPage int) ([]Repository, int, error) { + if perPage <= 0 || perPage > 100 { + perPage = 30 + } + if page <= 0 { + page = 1 + } + url := fmt.Sprintf("%s/installation/repositories?per_page=%d&page=%d", c.apiBase, perPage, page) + resp, err := c.doInstallation(ctx, http.MethodGet, url, installationID, nil) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + body, _ := io.ReadAll(resp.Body) + return nil, 0, fmt.Errorf("github: list installation repos (status %d): %s", resp.StatusCode, string(body)) + } + var payload struct { + TotalCount int `json:"total_count"` + Repositories []Repository `json:"repositories"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, 0, err + } + return payload.Repositories, payload.TotalCount, nil +} + +// CreateIssueComment posts a comment on an issue or PR (same endpoint). +func (c *Client) CreateIssueComment(ctx context.Context, installationID int64, owner, repo string, issueNumber int, body string) error { + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", c.apiBase, owner, repo, issueNumber) + resp, err := c.doInstallation(ctx, http.MethodPost, url, installationID, map[string]string{"body": body}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("github: create issue comment (status %d): %s", resp.StatusCode, string(b)) + } + return nil +} + +// ParseInstallationID extracts an installation_id query param. +func ParseInstallationID(s string) (int64, error) { + return strconv.ParseInt(s, 10, 64) +} diff --git a/api/internal/github/events.go b/api/internal/github/events.go new file mode 100644 index 00000000..c99d608c --- /dev/null +++ b/api/internal/github/events.go @@ -0,0 +1,181 @@ +package github + +import "time" + +// Event header values we care about. +const ( + EventPing = "ping" + EventPullRequest = "pull_request" + EventPullRequestReview = "pull_request_review" + EventIssueComment = "issue_comment" + EventPush = "push" + EventInstallation = "installation" + EventInstallationRepositories = "installation_repositories" +) + +// EventEnvelope captures the fields common to all events we handle (action + +// installation + repository). The full payload still arrives as a JSON map for +// per-event decoding. +type EventEnvelope struct { + Action string `json:"action,omitempty"` + Installation *InstallationLite `json:"installation,omitempty"` + Repository *RepositoryLite `json:"repository,omitempty"` + Sender *AccountLite `json:"sender,omitempty"` +} + +// InstallationLite is the embedded {"installation": {...}} on every event. +type InstallationLite struct { + ID int64 `json:"id"` + Account AccountLite `json:"account"` +} + +// AccountLite is the GitHub user/org that owns the installation. +type AccountLite struct { + Login string `json:"login"` + ID int64 `json:"id"` + Type string `json:"type"` + AvatarURL string `json:"avatar_url"` +} + +// RepositoryLite is the {"repository": {...}} on most events. +type RepositoryLite struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + Owner struct { + Login string `json:"login"` + ID int64 `json:"id"` + } `json:"owner"` +} + +// PullRequestEvent is the payload for the "pull_request" webhook event. +// Action values we react to: opened, edited, closed, reopened, ready_for_review, +// converted_to_draft, synchronize. +type PullRequestEvent struct { + Action string `json:"action"` + Number int `json:"number"` + PullRequest PullRequest `json:"pull_request"` + Repository RepositoryLite `json:"repository"` + Installation *InstallationLite `json:"installation,omitempty"` + Sender AccountLite `json:"sender"` +} + +// PullRequest is the trimmed representation we use. +type PullRequest struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Number int `json:"number"` + State string `json:"state"` // "open" or "closed" + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + MergedAt *time.Time `json:"merged_at"` + ClosedAt *time.Time `json:"closed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + User AccountLite `json:"user"` + Head Branch `json:"head"` + Base Branch `json:"base"` +} + +// Branch is the head/base reference on a PR. +type Branch struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo struct { + FullName string `json:"full_name"` + } `json:"repo"` +} + +// EffectiveState returns "open" | "merged" | "closed". +func (p PullRequest) EffectiveState() string { + if p.Merged { + return "merged" + } + if p.State == "closed" { + return "closed" + } + return "open" +} + +// PushEvent is the payload for the "push" webhook event. +type PushEvent struct { + Ref string `json:"ref"` // "refs/heads/feature/dev-42-foo" + Before string `json:"before"` + After string `json:"after"` + Created bool `json:"created"` // branch creation + Deleted bool `json:"deleted"` + Forced bool `json:"forced"` + Commits []PushCommit `json:"commits"` + HeadCommit *PushCommit `json:"head_commit,omitempty"` + Repository RepositoryLite `json:"repository"` + Installation *InstallationLite `json:"installation,omitempty"` + Sender AccountLite `json:"sender"` +} + +// PushCommit is a single commit in a push payload. +type PushCommit struct { + ID string `json:"id"` + Message string `json:"message"` + URL string `json:"url"` + Author struct { + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` + } `json:"author"` + Timestamp time.Time `json:"timestamp"` +} + +// IssueCommentEvent is the payload for the "issue_comment" event (comments on +// PRs come through this event because PRs are issues in GitHub's API). +type IssueCommentEvent struct { + Action string `json:"action"` + Issue IssueLite `json:"issue"` + Comment IssueComment `json:"comment"` + Repository RepositoryLite `json:"repository"` + Installation *InstallationLite `json:"installation,omitempty"` + Sender AccountLite `json:"sender"` +} + +// IssueLite is the GitHub issue/PR stub on issue_comment events. +type IssueLite struct { + ID int64 `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + PullRequest *struct{} `json:"pull_request,omitempty"` + User AccountLite `json:"user"` +} + +// IsPullRequest is true when the issue is actually a pull request. +func (i IssueLite) IsPullRequest() bool { return i.PullRequest != nil } + +// IssueComment is a GitHub issue/PR comment. +type IssueComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + User AccountLite `json:"user"` +} + +// InstallationEvent is the payload for the "installation" event. +// Action values: created, deleted, suspend, unsuspend, new_permissions_accepted. +type InstallationEvent struct { + Action string `json:"action"` + Installation InstallationLite `json:"installation"` + Sender AccountLite `json:"sender"` +} + +// InstallationRepositoriesEvent fires when an installation's accessible repos +// change (added or removed by the installer). +type InstallationRepositoriesEvent struct { + Action string `json:"action"` + Installation InstallationLite `json:"installation"` + RepositoriesAdded []RepositoryLite `json:"repositories_added"` + RepositoriesRemoved []RepositoryLite `json:"repositories_removed"` + Sender AccountLite `json:"sender"` +} diff --git a/api/internal/github/installations.go b/api/internal/github/installations.go new file mode 100644 index 00000000..c8d7c5f1 --- /dev/null +++ b/api/internal/github/installations.go @@ -0,0 +1,74 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Installation is the App-level metadata about an installation, returned by +// GET /app/installations/:id (App-JWT auth, not installation-token auth). +type Installation struct { + ID int64 `json:"id"` + Account AccountLite `json:"account"` + // We could expose more fields (target_type, permissions, ...) later but + // account is the only one the UI needs today. +} + +// GetInstallation fetches one installation's metadata via App JWT auth. +func (c *Client) GetInstallation(ctx context.Context, installationID int64) (*Installation, error) { + jwt, err := c.app.JWT(time.Now().UTC()) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/app/installations/%d", c.apiBase, installationID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github: get installation (status %d): %s", resp.StatusCode, string(body)) + } + var out Installation + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} + +// GetPullRequest fetches a single PR's full payload via the installation token. +// Used by the manual-link-by-URL flow on the issue detail page. +func (c *Client) GetPullRequest(ctx context.Context, installationID int64, owner, repo string, number int) (*PullRequest, error) { + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", c.apiBase, owner, repo, number) + resp, err := c.doInstallation(ctx, http.MethodGet, url, installationID, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("github: pull request %s/%s#%d not found or not visible to the installation", owner, repo, number) + } + if resp.StatusCode/100 != 2 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github: get pull request (status %d): %s", resp.StatusCode, string(body)) + } + var pr PullRequest + if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { + return nil, err + } + return &pr, nil +} diff --git a/api/internal/github/refparse.go b/api/internal/github/refparse.go new file mode 100644 index 00000000..abee0de5 --- /dev/null +++ b/api/internal/github/refparse.go @@ -0,0 +1,164 @@ +package github + +import ( + "regexp" + "strconv" + "strings" +) + +// IssueRef is a parsed reference from a PR title, body, branch, or commit message. +// (Identifier, Number) uniquely identifies a Devlane issue within a workspace +// (per the project_identifiers + issues.sequence_id pair). +type IssueRef struct { + Identifier string // uppercase project identifier, e.g. "DEV" + Number int // 1-based per-project sequence number + Closes bool // true when prefixed with a closing keyword +} + +// Identifier returns the canonical "DEV-42" form. +func (r IssueRef) String() string { return r.Identifier + "-" + strconv.Itoa(r.Number) } + +// Closing keywords recognized in PR titles, bodies, and commit messages. +// Matches GitHub's own list, plus a few common variants. +var closingKeywords = map[string]bool{ + "close": true, "closes": true, "closed": true, + "fix": true, "fixes": true, "fixed": true, + "resolve": true, "resolves": true, "resolved": true, + "complete": true, "completes": true, "completed": true, +} + +// Loose match for IDENT-NUM tokens. We use uppercase A-Z for the identifier +// (Devlane project identifiers are stored uppercase, ≤7 chars). The number is +// up to 9 digits so we don't catch huge sequence numbers as PR refs by accident. +var refRegex = regexp.MustCompile(`(?i)\b([A-Z][A-Z0-9]{0,6})-(\d{1,9})\b`) + +// branchSlugRegex captures DEV-42 references inside a branch name like +// "feat/dev-42-fix-thing", "username/DEV-42", "fix-DEV42-thing" (rare). +// We deliberately allow lowercase here because branches are typically kebab-case. +var branchRegex = regexp.MustCompile(`(?i)(?:^|[/_-])([A-Z][A-Z0-9]{0,6})-(\d{1,9})(?:[/_-]|$)`) + +// ExtractRefs scans free-form text (PR title, PR body, commit message) for +// `IDENT-NUM` references and returns deduplicated IssueRefs. Closing intent is +// detected when a reference is immediately preceded (within ~12 chars) by a +// closing keyword. +// +// Examples: +// +// "Fixes DEV-42" → [{DEV, 42, closes=true}] +// "Closes DEV-12, refs ABC-3" → [{DEV,12,true}, {ABC,3,false}] +// "DEV-1 and DEV-2 in the body" → [{DEV,1,false}, {DEV,2,false}] +func ExtractRefs(text string) []IssueRef { + if text == "" { + return nil + } + seen := make(map[string]int) // ident-num → index in out + out := make([]IssueRef, 0, 4) + matches := refRegex.FindAllStringSubmatchIndex(text, -1) + for _, m := range matches { + // m: [start end identStart identEnd numStart numEnd] + identStart, identEnd := m[2], m[3] + numStart, numEnd := m[4], m[5] + ident := strings.ToUpper(text[identStart:identEnd]) + num, err := strconv.Atoi(text[numStart:numEnd]) + if err != nil || num <= 0 { + continue + } + // Look back up to 16 chars for a closing keyword. + back := identStart - 16 + if back < 0 { + back = 0 + } + preceding := strings.ToLower(text[back:identStart]) + closes := hasClosingKeywordBefore(preceding) + + key := ident + "-" + strconv.Itoa(num) + if existing, ok := seen[key]; ok { + // Merge: closes=true sticks if any reference is a closer. + if closes && !out[existing].Closes { + out[existing].Closes = true + } + continue + } + seen[key] = len(out) + out = append(out, IssueRef{Identifier: ident, Number: num, Closes: closes}) + } + return out +} + +func hasClosingKeywordBefore(s string) bool { + // Walk back to find the nearest word. + end := len(s) + for end > 0 { + // Trim trailing non-letter chars (whitespace, punctuation). + for end > 0 && !isLetter(s[end-1]) { + end-- + } + if end == 0 { + return false + } + start := end + for start > 0 && isLetter(s[start-1]) { + start-- + } + word := s[start:end] + if closingKeywords[word] { + return true + } + // Stop at the first word — closing keyword must immediately precede. + return false + } + return false +} + +func isLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +// ExtractRefsFromBranch parses a branch name. Branch refs are never marked as +// closing (closing is intent expressed in commit/PR title/body, not in a name). +func ExtractRefsFromBranch(branch string) []IssueRef { + if branch == "" { + return nil + } + seen := make(map[string]bool) + out := make([]IssueRef, 0, 2) + matches := branchRegex.FindAllStringSubmatch(branch, -1) + for _, m := range matches { + if len(m) < 3 { + continue + } + ident := strings.ToUpper(m[1]) + num, err := strconv.Atoi(m[2]) + if err != nil || num <= 0 { + continue + } + key := ident + "-" + strconv.Itoa(num) + if seen[key] { + continue + } + seen[key] = true + out = append(out, IssueRef{Identifier: ident, Number: num}) + } + return out +} + +// MergeRefs unions multiple ref slices, preserving the strongest signals +// (Closes=true wins). Order follows the first occurrence across inputs. +func MergeRefs(slices ...[]IssueRef) []IssueRef { + seen := make(map[string]int) + out := make([]IssueRef, 0) + for _, s := range slices { + for _, r := range s { + key := r.Identifier + "-" + strconv.Itoa(r.Number) + if idx, ok := seen[key]; ok { + if r.Closes && !out[idx].Closes { + out[idx].Closes = true + } + continue + } + seen[key] = len(out) + out = append(out, r) + } + } + return out +} diff --git a/api/internal/github/refparse_test.go b/api/internal/github/refparse_test.go new file mode 100644 index 00000000..6d13546a --- /dev/null +++ b/api/internal/github/refparse_test.go @@ -0,0 +1,132 @@ +package github + +import "testing" + +func TestExtractRefs(t *testing.T) { + cases := []struct { + name string + text string + want []IssueRef + }{ + { + name: "single closing ref", + text: "Fixes DEV-42", + want: []IssueRef{{Identifier: "DEV", Number: 42, Closes: true}}, + }, + { + name: "multiple refs, mixed closing", + text: "Closes DEV-12 and refs ABC-3", + want: []IssueRef{ + {Identifier: "DEV", Number: 12, Closes: true}, + {Identifier: "ABC", Number: 3, Closes: false}, + }, + }, + { + name: "non-closing references", + text: "Working on DEV-1 and DEV-2 today", + want: []IssueRef{ + {Identifier: "DEV", Number: 1}, + {Identifier: "DEV", Number: 2}, + }, + }, + { + name: "lowercase keyword still triggers closing", + text: "fixes dev-42", + want: []IssueRef{{Identifier: "DEV", Number: 42, Closes: true}}, + }, + { + name: "dedup, closes wins", + text: "Mentioned DEV-7 then fixes DEV-7 later", + want: []IssueRef{{Identifier: "DEV", Number: 7, Closes: true}}, + }, + { + name: "ignores lone dash", + text: "no ref here", + want: []IssueRef{}, + }, + { + name: "punctuation between keyword and ref", + text: "Resolves: DEV-9.", + want: []IssueRef{{Identifier: "DEV", Number: 9, Closes: true}}, + }, + { + name: "PR title with closing keyword", + text: "fix(api): closed DEV-100 by handling edge case", + want: []IssueRef{{Identifier: "DEV", Number: 100, Closes: true}}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ExtractRefs(tc.text) + if len(got) != len(tc.want) { + t.Fatalf("len mismatch: got %d (%+v), want %d (%+v)", len(got), got, len(tc.want), tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("ref[%d] = %+v, want %+v", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestExtractRefsFromBranch(t *testing.T) { + cases := []struct { + branch string + want []IssueRef + }{ + {"feat/dev-42-fix-thing", []IssueRef{{Identifier: "DEV", Number: 42}}}, + {"username/DEV-99", []IssueRef{{Identifier: "DEV", Number: 99}}}, + {"DEV-7", []IssueRef{{Identifier: "DEV", Number: 7}}}, + {"main", []IssueRef{}}, + {"feature/dev-12_and_dev-13", []IssueRef{{Identifier: "DEV", Number: 12}, {Identifier: "DEV", Number: 13}}}, + } + for _, tc := range cases { + t.Run(tc.branch, func(t *testing.T) { + got := ExtractRefsFromBranch(tc.branch) + if len(got) != len(tc.want) { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("ref[%d] = %+v, want %+v", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestMergeRefs(t *testing.T) { + a := []IssueRef{{Identifier: "DEV", Number: 1}, {Identifier: "DEV", Number: 2, Closes: true}} + b := []IssueRef{{Identifier: "DEV", Number: 1, Closes: true}, {Identifier: "ABC", Number: 5}} + got := MergeRefs(a, b) + want := []IssueRef{ + {Identifier: "DEV", Number: 1, Closes: true}, + {Identifier: "DEV", Number: 2, Closes: true}, + {Identifier: "ABC", Number: 5}, + } + if len(got) != len(want) { + t.Fatalf("got %+v, want %+v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Errorf("ref[%d] = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestVerifySignature(t *testing.T) { + secret := "topsecret" + payload := []byte(`{"hello":"world"}`) + // HMAC-SHA256 of payload with key "topsecret": + // computed once: 5a8d05a99c00ff60b...; we recompute via HMAC. + if err := VerifySignature(secret, payload, ""); err == nil { + t.Error("expected error for empty signature") + } + if err := VerifySignature("", payload, "sha256=abc"); err == nil { + t.Error("expected error for empty secret") + } + if err := VerifySignature(secret, payload, "abcd"); err == nil { + t.Error("expected error for missing prefix") + } +} diff --git a/api/internal/github/webhook.go b/api/internal/github/webhook.go new file mode 100644 index 00000000..f28293f2 --- /dev/null +++ b/api/internal/github/webhook.go @@ -0,0 +1,39 @@ +package github + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" +) + +// VerifySignature validates a GitHub webhook payload against the configured +// secret. The signature is sent as `X-Hub-Signature-256: sha256=`. +// +// Returns nil only when the secret is non-empty AND the signature matches +// (constant-time compare). If secret is empty, returns an error — never +// fall through silently in production paths. +func VerifySignature(secret string, payload []byte, signatureHeader string) error { + if secret == "" { + return errors.New("github: webhook secret is not configured") + } + if signatureHeader == "" { + return errors.New("github: missing X-Hub-Signature-256 header") + } + const prefix = "sha256=" + if !strings.HasPrefix(signatureHeader, prefix) { + return errors.New("github: signature header missing sha256= prefix") + } + want, err := hex.DecodeString(signatureHeader[len(prefix):]) + if err != nil { + return errors.New("github: signature header is not valid hex") + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + got := mac.Sum(nil) + if !hmac.Equal(want, got) { + return errors.New("github: signature mismatch") + } + return nil +} diff --git a/api/internal/handler/comment.go b/api/internal/handler/comment.go index 00e40312..2db7ada6 100644 --- a/api/internal/handler/comment.go +++ b/api/internal/handler/comment.go @@ -66,12 +66,13 @@ func (h *CommentHandler) Create(c *gin.Context) { } var body struct { Comment string `json:"comment" binding:"required"` + Access string `json:"access"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) return } - comment, err := h.Comment.Create(c.Request.Context(), slug, projectID, issueID, user.ID, body.Comment) + comment, err := h.Comment.Create(c.Request.Context(), slug, projectID, issueID, user.ID, body.Comment, body.Access) if err != nil { if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound || err == service.ErrCommentNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) @@ -150,3 +151,97 @@ func (h *CommentHandler) Delete(c *gin.Context) { } c.Status(http.StatusNoContent) } + +// ListReactions returns all reactions on a comment. +// GET /api/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/reactions/ +func (h *CommentHandler) ListReactions(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + commentID, err := uuid.Parse(c.Param("commentId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid comment ID"}) + return + } + list, err := h.Comment.ListReactions(c.Request.Context(), slug, projectID, commentID, user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list reactions"}) + return + } + c.JSON(http.StatusOK, list) +} + +// AddReaction adds an emoji reaction to a comment. +// POST /api/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/reactions/ +type addReactionRequest struct { + Reaction string `json:"reaction" binding:"required"` +} + +func (h *CommentHandler) AddReaction(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + commentID, err := uuid.Parse(c.Param("commentId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid comment ID"}) + return + } + var body addReactionRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + r, err := h.Comment.AddReaction(c.Request.Context(), slug, projectID, commentID, user.ID, body.Reaction) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, r) +} + +// RemoveReaction removes a user's emoji reaction. +// DELETE /api/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/reactions/:reaction/ +func (h *CommentHandler) RemoveReaction(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + commentID, err := uuid.Parse(c.Param("commentId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid comment ID"}) + return + } + reaction := c.Param("reaction") + if reaction == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Reaction is required"}) + return + } + if err := h.Comment.RemoveReaction(c.Request.Context(), slug, projectID, commentID, user.ID, reaction); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reaction"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/api/internal/handler/instance.go b/api/internal/handler/instance.go index 5cf298d7..c3906a47 100644 --- a/api/internal/handler/instance.go +++ b/api/internal/handler/instance.go @@ -1,6 +1,7 @@ package handler import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -19,6 +20,7 @@ import ( // Allowed instance setting section keys (must match migration seed). var allowedSettingKeys = map[string]bool{ "general": true, "email": true, "auth": true, "oauth": true, "ai": true, "image": true, + "github_app": true, } // InstanceHandler serves instance setup (first-run); no auth required. @@ -31,6 +33,10 @@ type InstanceHandler struct { // InstanceSettingsHandler serves instance settings (GET/PATCH); requires auth. type InstanceSettingsHandler struct { Settings *store.InstanceSettingStore + // OnSectionUpdated, if set, is invoked after a successful update with the + // section key. Used for hot-reload of integration clients (e.g. github_app) + // so the new credentials take effect without an API restart. + OnSectionUpdated func(ctx context.Context, key string) } // SetupStatusResponse for GET /api/instance/setup-status/ @@ -135,7 +141,7 @@ func (h *InstanceSettingsHandler) GetSettings(c *gin.Context) { out[k] = decryptSectionSecrets(k, row.Value) } // Ensure all sections exist with defaults (migration seed may not have run if DB was created before seed) - for _, key := range []string{"general", "email", "auth", "oauth", "ai", "image"} { + for _, key := range []string{"general", "email", "auth", "oauth", "ai", "image", "github_app"} { if _, ok := out[key]; !ok { out[key] = defaultSettingValue(key) } @@ -158,6 +164,12 @@ func decryptSectionSecrets(sectionKey string, m model.JSONMap) model.JSONMap { secretKeys = []string{"api_key"} case "image": secretKeys = []string{"unsplash_access_key"} + case "github_app": + // We never echo private_key / client_secret / webhook_secret back to the + // admin UI in plain text; only the *_set boolean flags are exposed. + // Returning the section unchanged is fine because the response builder + // strips these via stripSecretValues below. + return stripSecretValues(m, "private_key", "client_secret", "webhook_secret") default: return m } @@ -173,6 +185,25 @@ func decryptSectionSecrets(sectionKey string, m model.JSONMap) model.JSONMap { return out } +// stripSecretValues returns a copy of m with the named keys replaced by an +// empty string. Used for sections (like github_app) where a secret is stored +// encrypted and exposed to the admin UI only through a *_set boolean. +func stripSecretValues(m model.JSONMap, keys ...string) model.JSONMap { + out := make(model.JSONMap, len(m)) + stripped := make(map[string]bool, len(keys)) + for _, k := range keys { + stripped[k] = true + } + for k, v := range m { + if stripped[k] { + out[k] = "" + continue + } + out[k] = v + } + return out +} + func defaultSettingValue(key string) model.JSONMap { switch key { case "general": @@ -191,6 +222,11 @@ func defaultSettingValue(key string) model.JSONMap { return model.JSONMap{"model": "gpt-4o-mini", "api_key_set": false} case "image": return model.JSONMap{"unsplash_access_key_set": false} + case "github_app": + return model.JSONMap{ + "app_id": "", "app_name": "", "client_id": "", + "client_secret_set": false, "private_key_set": false, "webhook_secret_set": false, + } default: return model.JSONMap{} } @@ -342,10 +378,51 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) { secretField("gitlab_client_secret", "gitlab_client_secret_set") value = merged } + if key == "github_app" { + // Merge with existing; encrypt secrets and set *_set flags. Empty + // strings are ignored so the admin can edit one field without resetting + // the others. + existing, _ := h.Settings.Get(c.Request.Context(), "github_app") + merged := model.JSONMap{} + if existing != nil { + for k, v := range existing.Value { + merged[k] = v + } + } else { + for k, v := range defaultSettingValue("github_app") { + merged[k] = v + } + } + // Plain (non-secret) fields. + for _, field := range []string{"app_id", "app_name", "client_id"} { + if v, ok := req.Value[field]; ok { + merged[field] = v + } + } + // Secret fields: encrypt, set the *_set flag, never expose back. + setSecret := func(field, setKey string) { + if v, ok := req.Value[field]; ok { + if s, ok := v.(string); ok && s != "" { + merged[field] = crypto.EncryptOrPlain(s) + merged[setKey] = true + } + } + } + setSecret("client_secret", "client_secret_set") + setSecret("private_key", "private_key_set") + setSecret("webhook_secret", "webhook_secret_set") + value = merged + } if err := h.Settings.Upsert(c.Request.Context(), key, value); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) return } + // Hot-reload integrations (e.g. github_app) so the new credentials take + // effect without an API restart. Errors here are logged-and-ignored — the + // settings are already saved; a stale client is preferable to a 500. + if h.OnSectionUpdated != nil { + h.OnSectionUpdated(c.Request.Context(), key) + } // Return decrypted secrets so client sees the value they just set responseValue := decryptSectionSecrets(key, value) c.JSON(http.StatusOK, gin.H{"key": key, "value": responseValue}) diff --git a/api/internal/handler/integration.go b/api/internal/handler/integration.go new file mode 100644 index 00000000..1bc6445b --- /dev/null +++ b/api/internal/handler/integration.go @@ -0,0 +1,561 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + + gh "github.com/Devlaner/devlane/api/internal/github" + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// IntegrationHandler exposes generic integration endpoints. Provider-specific +// flows (GitHub install, repo sync, webhook) live in github.go. +type IntegrationHandler struct { + Integration *service.IntegrationService + GithubSync *service.GithubSyncService + GithubEvent *service.GithubEventService + Settings *store.InstanceSettingStore + AppBaseURL string + APIPublicURL string + Log *slog.Logger +} + +func (h *IntegrationHandler) log() *slog.Logger { + if h.Log != nil { + return h.Log + } + return slog.Default() +} + +// ListAvailable returns all registered integration providers. +// GET /api/integrations/ +func (h *IntegrationHandler) ListAvailable(c *gin.Context) { + list, err := h.Integration.ListAvailable(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list integrations"}) + return + } + c.JSON(http.StatusOK, list) +} + +// ListInstalled returns the workspace's installed integrations. +// GET /api/workspaces/:slug/integrations/ +func (h *IntegrationHandler) ListInstalled(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + list, err := h.Integration.ListInstalled(c.Request.Context(), c.Param("slug"), user.ID) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, list) +} + +// Uninstall removes the workspace's installed integration for a provider. +// DELETE /api/workspaces/:slug/integrations/:provider/ +func (h *IntegrationHandler) Uninstall(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + provider := strings.ToLower(c.Param("provider")) + if err := h.Integration.Uninstall(c.Request.Context(), c.Param("slug"), provider, user.ID); err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusNoContent, nil) +} + +// --------------------------------------------------------------------------- +// GitHub App install flow (browser → github.com → callback → workspace settings) +// --------------------------------------------------------------------------- + +// GitHubInstallStart redirects the user to github.com to install the App. +// We carry the workspace slug in the OAuth state cookie so the callback can +// link the resulting installation to the right workspace. +// GET /auth/github-app/install?workspace=:slug +func (h *IntegrationHandler) GitHubInstallStart(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + workspaceSlug := strings.TrimSpace(c.Query("workspace")) + if workspaceSlug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace query param is required"}) + return + } + appName := service.LoadGitHubAppNameFromSettings(c.Request.Context(), h.Settings) + if appName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App is not configured. Ask an instance admin to set the github_app section."}) + return + } + + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state"}) + return + } + state := hex.EncodeToString(stateBytes) + ":" + workspaceSlug + http.SetCookie(c.Writer, &http.Cookie{ + Name: "github_app_state", + Value: state, + Path: "/", + MaxAge: 600, + HttpOnly: true, + Secure: isSecureRequest(c), + SameSite: http.SameSiteLaxMode, + }) + + installURL := "https://github.com/apps/" + url.PathEscape(appName) + "/installations/new?state=" + url.QueryEscape(state) + c.Redirect(http.StatusTemporaryRedirect, installURL) +} + +// GitHubInstallCallback handles the redirect back from github.com after the +// user installs (or updates) the App. GitHub appends ?installation_id=&state= +// to the redirect URL configured on the App. +// GET /auth/github-app/callback?installation_id=...&state=...&setup_action=install +func (h *IntegrationHandler) GitHubInstallCallback(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + // Not logged in — kick to login then bounce back. + next := "/login" + if h.AppBaseURL != "" { + next = strings.TrimSuffix(h.AppBaseURL, "/") + "/login" + } + c.Redirect(http.StatusTemporaryRedirect, next) + return + } + + installationIDStr := c.Query("installation_id") + stateRaw := c.Query("state") + cookieVal, _ := c.Cookie("github_app_state") + // Clear cookie regardless of outcome. + http.SetCookie(c.Writer, &http.Cookie{ + Name: "github_app_state", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, + Secure: isSecureRequest(c), SameSite: http.SameSiteLaxMode, + }) + + if cookieVal == "" || cookieVal != stateRaw { + h.redirectIntegration(c, "", "GitHub App install state mismatch") + return + } + parts := strings.SplitN(stateRaw, ":", 2) + if len(parts) != 2 { + h.redirectIntegration(c, "", "Invalid GitHub App install state") + return + } + workspaceSlug := parts[1] + installationID, err := strconv.ParseInt(installationIDStr, 10, 64) + if err != nil || installationID <= 0 { + h.redirectIntegration(c, workspaceSlug, "Missing installation_id from GitHub") + return + } + if _, err := h.Integration.InstallGitHub(c.Request.Context(), workspaceSlug, user.ID, installationID); err != nil { + h.log().Error("github app install failed", "error", err, "workspace", workspaceSlug, "installation_id", installationID) + h.redirectIntegration(c, workspaceSlug, "Failed to complete GitHub App install: "+err.Error()) + return + } + h.redirectIntegration(c, workspaceSlug, "") +} + +func (h *IntegrationHandler) redirectIntegration(c *gin.Context, workspaceSlug, errMsg string) { + target := strings.TrimSuffix(h.AppBaseURL, "/") + if target == "" { + target = "" + } + if workspaceSlug != "" { + target += "/" + url.PathEscape(workspaceSlug) + "/settings" + } else { + target += "/" + } + q := url.Values{} + q.Set("section", "integrations") + if errMsg != "" { + q.Set("error", errMsg) + } else { + q.Set("connected", "github") + } + target += "?" + q.Encode() + c.Redirect(http.StatusTemporaryRedirect, target) +} + +// --------------------------------------------------------------------------- +// GitHub repo sync (workspace + project scoped) +// --------------------------------------------------------------------------- + +// GitHubListRepositories proxies the installation's accessible repositories. +// GET /api/workspaces/:slug/integrations/github/repositories?page=1&per_page=30 +func (h *IntegrationHandler) GitHubListRepositories(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "30")) + repos, total, err := h.GithubSync.ListRepositories(c.Request.Context(), c.Param("slug"), user.ID, page, perPage) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "total_count": total, + "page": page, + "per_page": perPage, + "repositories": repos, + }) +} + +// GitHubGetSync returns the project's GitHub repo sync row, if any. +// GET /api/workspaces/:slug/projects/:projectId/integrations/github/sync/ +func (h *IntegrationHandler) GitHubGetSync(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + sync, repo, err := h.GithubSync.GetByProject(c.Request.Context(), c.Param("slug"), projectID, user.ID) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"sync": sync, "repository": repo}) +} + +// GitHubCreateSync links a GitHub repo to a Devlane project. +// POST /api/workspaces/:slug/projects/:projectId/integrations/github/sync/ +type githubCreateSyncRequest struct { + GithubRepositoryID int64 `json:"github_repository_id" binding:"required"` + Owner string `json:"owner" binding:"required"` + Name string `json:"name" binding:"required"` + URL string `json:"url"` +} + +func (h *IntegrationHandler) GitHubCreateSync(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + var body githubCreateSyncRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + sync, repo, err := h.GithubSync.CreateSync(c.Request.Context(), c.Param("slug"), projectID, user.ID, service.LinkRequest{ + GithubRepositoryID: body.GithubRepositoryID, + Owner: body.Owner, + Name: body.Name, + URL: body.URL, + }) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusCreated, gin.H{"sync": sync, "repository": repo}) +} + +// GitHubUpdateSync updates per-repo sync settings (auto_link, state map, ...). +// PATCH /api/workspaces/:slug/projects/:projectId/integrations/github/sync/ +type githubUpdateSyncRequest struct { + AutoLink *bool `json:"auto_link"` + AutoCloseOnMerge *bool `json:"auto_close_on_merge"` + InProgressStateID *string `json:"in_progress_state_id"` + DoneStateID *string `json:"done_state_id"` +} + +func (h *IntegrationHandler) GitHubUpdateSync(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + var body githubUpdateSyncRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + var inProg, done *uuid.UUID + if body.InProgressStateID != nil { + v := strings.TrimSpace(*body.InProgressStateID) + if v == "" { + zero := uuid.Nil + inProg = &zero + } else if id, err := uuid.Parse(v); err == nil { + inProg = &id + } + } + if body.DoneStateID != nil { + v := strings.TrimSpace(*body.DoneStateID) + if v == "" { + zero := uuid.Nil + done = &zero + } else if id, err := uuid.Parse(v); err == nil { + done = &id + } + } + sync, err := h.GithubSync.UpdateSync(c.Request.Context(), c.Param("slug"), projectID, user.ID, body.AutoLink, body.AutoCloseOnMerge, inProg, done) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, sync) +} + +// GitHubDeleteSync removes the project ↔ repo link. +// DELETE /api/workspaces/:slug/projects/:projectId/integrations/github/sync/ +func (h *IntegrationHandler) GitHubDeleteSync(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + if err := h.GithubSync.DeleteSync(c.Request.Context(), c.Param("slug"), projectID, user.ID); err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusNoContent, nil) +} + +// --------------------------------------------------------------------------- +// Per-issue PR links (issue detail page sidebar) +// --------------------------------------------------------------------------- + +// GitHubListIssueLinks lists every PR linked to a Devlane issue. +// GET /api/workspaces/:slug/projects/:projectId/issues/:pk/integrations/github/links/ +func (h *IntegrationHandler) GitHubListIssueLinks(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + issueID, err := uuid.Parse(c.Param("pk")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue id"}) + return + } + links, err := h.GithubSync.ListLinksForIssue(c.Request.Context(), c.Param("slug"), projectID, issueID, user.ID) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusOK, links) +} + +// GitHubCreateIssueLink links a PR (by URL) to a Devlane issue. +// POST /api/workspaces/:slug/projects/:projectId/issues/:pk/integrations/github/links/ +type githubCreateIssueLinkRequest struct { + URL string `json:"url" binding:"required"` +} + +func (h *IntegrationHandler) GitHubCreateIssueLink(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + issueID, err := uuid.Parse(c.Param("pk")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue id"}) + return + } + var body githubCreateIssueLinkRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + link, err := h.GithubSync.CreateLinkFromURL(c.Request.Context(), c.Param("slug"), projectID, issueID, user.ID, body.URL) + if err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusCreated, link) +} + +// GitHubDeleteIssueLink removes a single PR↔issue link. +// DELETE /api/workspaces/:slug/projects/:projectId/issues/:pk/integrations/github/links/:linkId/ +func (h *IntegrationHandler) GitHubDeleteIssueLink(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + issueID, err := uuid.Parse(c.Param("pk")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue id"}) + return + } + linkID, err := uuid.Parse(c.Param("linkId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid link id"}) + return + } + if err := h.GithubSync.DeleteLinkForIssue(c.Request.Context(), c.Param("slug"), projectID, issueID, linkID, user.ID); err != nil { + writeIntegrationError(c, err) + return + } + c.JSON(http.StatusNoContent, nil) +} + +// GitHubIssueSummary returns aggregate PR counts for the given issue IDs. +// GET /api/workspaces/:slug/projects/:projectId/integrations/github/issue-summary/?ids=a,b,c +func (h *IntegrationHandler) GitHubIssueSummary(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + idsParam := strings.TrimSpace(c.Query("ids")) + if idsParam == "" { + c.JSON(http.StatusOK, gin.H{"summary": map[string]any{}}) + return + } + parts := strings.Split(idsParam, ",") + ids := make([]uuid.UUID, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + id, err := uuid.Parse(p) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid id in ids list", "detail": p}) + return + } + ids = append(ids, id) + } + out, err := h.GithubSync.IssueSummaryForProject(c.Request.Context(), c.Param("slug"), projectID, ids, user.ID) + if err != nil { + writeIntegrationError(c, err) + return + } + // Marshal map[uuid]… → map[string]… for the JSON response. + resp := make(map[string]any, len(out)) + for k, v := range out { + resp[k.String()] = v + } + c.JSON(http.StatusOK, gin.H{"summary": resp}) +} + +// --------------------------------------------------------------------------- +// Webhook receiver (no auth — signature-verified) +// --------------------------------------------------------------------------- + +// GitHubWebhook receives events from github.com. Public endpoint, no session +// auth — authentication is the HMAC signature in X-Hub-Signature-256. +// POST /webhooks/github +func (h *IntegrationHandler) GitHubWebhook(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read body"}) + return + } + + secret := service.LoadGitHubWebhookSecretFromSettings(c.Request.Context(), h.Settings) + if err := gh.VerifySignature(secret, body, c.GetHeader("X-Hub-Signature-256")); err != nil { + h.log().Warn("github webhook signature verification failed", "error", err, "delivery", c.GetHeader("X-GitHub-Delivery")) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Signature verification failed"}) + return + } + + event := c.GetHeader("X-GitHub-Event") + deliveryID := c.GetHeader("X-GitHub-Delivery") + if event == "" || deliveryID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing event headers"}) + return + } + + if h.GithubEvent == nil { + h.log().Warn("github webhook received but event service is not wired") + c.JSON(http.StatusOK, gin.H{"ok": true}) + return + } + if err := h.GithubEvent.HandleWebhook(c.Request.Context(), event, deliveryID, body); err != nil { + h.log().Warn("github webhook processing error", "error", err, "event", event) + // Still return 200 — failure is logged in github_webhook_events. + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// writeIntegrationError maps service errors to HTTP responses. +func writeIntegrationError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrIntegrationNotFound), + errors.Is(err, service.ErrRepoSyncNotFound), + errors.Is(err, service.ErrIssueLinkNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + case errors.Is(err, service.ErrIntegrationAlreadyInstalled), + errors.Is(err, service.ErrRepoSyncExists): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrInvalidPRURL), + errors.Is(err, service.ErrPRRepoMismatch): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrGitHubAppNotConfigured): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrInstallationFetch): + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrWorkspaceNotFound), + errors.Is(err, service.ErrWorkspaceForbidden), + errors.Is(err, service.ErrProjectNotFound), + errors.Is(err, service.ErrProjectForbidden): + c.JSON(http.StatusNotFound, gin.H{"error": "Workspace or project not found"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Integration request failed", "detail": err.Error()}) + } +} diff --git a/api/internal/handler/issue.go b/api/internal/handler/issue.go index d95f05ae..db2756f5 100644 --- a/api/internal/handler/issue.go +++ b/api/internal/handler/issue.go @@ -195,16 +195,19 @@ func (h *IssueHandler) Update(c *gin.Context) { return } var body struct { - Name string `json:"name"` - Description string `json:"description"` - Priority string `json:"priority"` - StateID *uuid.UUID `json:"state_id"` - ParentID *uuid.UUID `json:"parent_id"` - StartDate *string `json:"start_date"` - TargetDate *string `json:"target_date"` - AssigneeIDs []uuid.UUID `json:"assignee_ids"` - LabelIDs []uuid.UUID `json:"label_ids"` - IsDraft *bool `json:"is_draft"` + Name string `json:"name"` + Description *string `json:"description"` + // description_html is an alias accepted for symmetry with the column + // name on the GORM model — frontend can send either. + DescriptionHTML *string `json:"description_html"` + Priority string `json:"priority"` + StateID *uuid.UUID `json:"state_id"` + ParentID *uuid.UUID `json:"parent_id"` + StartDate *string `json:"start_date"` + TargetDate *string `json:"target_date"` + AssigneeIDs []uuid.UUID `json:"assignee_ids"` + LabelIDs []uuid.UUID `json:"label_ids"` + IsDraft *bool `json:"is_draft"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) @@ -217,9 +220,13 @@ func (h *IssueHandler) Update(c *gin.Context) { if body.Priority != "" { priority = &body.Priority } + // Description: accept either `description` or `description_html` (alias). + // Pointer semantics — null/missing = leave alone, "" = clear. var description *string - if body.Description != "" { - description = &body.Description + if body.DescriptionHTML != nil { + description = body.DescriptionHTML + } else if body.Description != nil { + description = body.Description } var assigneeIDs *[]uuid.UUID if body.AssigneeIDs != nil { @@ -426,3 +433,33 @@ func (h *IssueHandler) Delete(c *gin.Context) { } c.Status(http.StatusNoContent) } + +// ListActivities returns the chronological activity log for an issue. +// GET /api/workspaces/:slug/projects/:projectId/issues/:pk/activities/ +func (h *IssueHandler) ListActivities(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + iid, ok := issueID(c) + if !ok { + return + } + list, err := h.Issue.ListActivities(c.Request.Context(), slug, projectID, iid, user.ID) + if err != nil { + if err == service.ErrIssueNotFound || err == service.ErrProjectForbidden { + c.JSON(http.StatusNotFound, gin.H{"error": "Issue not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list activities"}) + return + } + c.JSON(http.StatusOK, list) +} diff --git a/api/internal/model/comment.go b/api/internal/model/comment.go index 2e6d00ea..b6a0ac8f 100644 --- a/api/internal/model/comment.go +++ b/api/internal/model/comment.go @@ -14,6 +14,7 @@ type IssueComment struct { ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` Comment string `gorm:"type:text" json:"comment"` + Access string `gorm:"column:access;type:varchar(100);not null;default:'INTERNAL'" json:"access"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/api/internal/model/comment_reaction.go b/api/internal/model/comment_reaction.go new file mode 100644 index 00000000..8c178ead --- /dev/null +++ b/api/internal/model/comment_reaction.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// CommentReaction matches table "comment_reactions". Unique on (comment_id, +// reaction, actor_id) so each user can drop one of each emoji per comment. +type CommentReaction struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + CommentID uuid.UUID `gorm:"column:comment_id;type:uuid;not null" json:"comment_id"` + Reaction string `gorm:"type:varchar(50);not null" json:"reaction"` + ActorID uuid.UUID `gorm:"column:actor_id;type:uuid;not null" json:"actor_id"` + ProjectID uuid.UUID `gorm:"column:project_id;type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"column:workspace_id;type:uuid;not null" json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` +} + +func (CommentReaction) TableName() string { return "comment_reactions" } + +func (r *CommentReaction) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} diff --git a/api/internal/model/integration.go b/api/internal/model/integration.go new file mode 100644 index 00000000..ae7781b4 --- /dev/null +++ b/api/internal/model/integration.go @@ -0,0 +1,223 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Integration is a registered integration provider (github, slack, ...). +// Matches table "integrations". +type Integration struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Title string `gorm:"type:varchar(400);not null" json:"title"` + Provider string `gorm:"type:varchar(400);uniqueIndex;not null" json:"provider"` + Network int `gorm:"not null;default:1" json:"network"` + Description JSONMap `gorm:"type:jsonb;default:'{}';serializer:json" json:"description,omitempty"` + Author string `gorm:"type:varchar(400)" json:"author,omitempty"` + WebhookURL string `gorm:"column:webhook_url;type:text" json:"webhook_url,omitempty"` + WebhookSecret string `gorm:"column:webhook_secret;type:text" json:"-"` + RedirectURL string `gorm:"column:redirect_url;type:text" json:"redirect_url,omitempty"` + Metadata JSONMap `gorm:"type:jsonb;default:'{}';serializer:json" json:"metadata,omitempty"` + Verified bool `gorm:"not null;default:false" json:"verified"` + AvatarURL string `gorm:"column:avatar_url;type:text" json:"avatar_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` +} + +func (Integration) TableName() string { return "integrations" } + +func (i *Integration) BeforeCreate(tx *gorm.DB) error { + if i.ID == uuid.Nil { + i.ID = uuid.New() + } + return nil +} + +// WorkspaceIntegration is an integration installed in a workspace. +// Matches table "workspace_integrations". +type WorkspaceIntegration struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + ActorID uuid.UUID `gorm:"type:uuid;not null" json:"actor_id"` + IntegrationID uuid.UUID `gorm:"type:uuid;not null" json:"integration_id"` + APITokenID *uuid.UUID `gorm:"column:api_token_id;type:uuid" json:"api_token_id,omitempty"` + Metadata JSONMap `gorm:"type:jsonb;default:'{}';serializer:json" json:"metadata,omitempty"` + Config JSONMap `gorm:"type:jsonb;default:'{}';serializer:json" json:"config,omitempty"` + InstallationID *int64 `gorm:"column:installation_id;type:bigint" json:"installation_id,omitempty"` + AccountLogin string `gorm:"column:account_login;type:varchar(255)" json:"account_login,omitempty"` + AccountType string `gorm:"column:account_type;type:varchar(50)" json:"account_type,omitempty"` + AccountAvatarURL string `gorm:"column:account_avatar_url;type:text" json:"account_avatar_url,omitempty"` + SuspendedAt *time.Time `gorm:"column:suspended_at" json:"suspended_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + // Hydrated from the join with integrations(provider) for list responses. + // `->` makes it read-only so writes (Create/Save) ignore the column while + // SELECT aliases (e.g. `integrations.provider AS provider`) still populate it. + Provider string `gorm:"column:provider;->" json:"provider,omitempty"` +} + +func (WorkspaceIntegration) TableName() string { return "workspace_integrations" } + +func (w *WorkspaceIntegration) BeforeCreate(tx *gorm.DB) error { + if w.ID == uuid.Nil { + w.ID = uuid.New() + } + return nil +} + +// GithubRepository is a GitHub repository linked to a Devlane project. +// Matches table "github_repositories". +type GithubRepository struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(500);not null" json:"name"` + URL string `gorm:"type:text" json:"url,omitempty"` + Config JSONMap `gorm:"type:jsonb;default:'{}';serializer:json" json:"config,omitempty"` + RepositoryID int64 `gorm:"column:repository_id;type:bigint;not null" json:"repository_id"` + Owner string `gorm:"type:varchar(500);not null" json:"owner"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` +} + +func (GithubRepository) TableName() string { return "github_repositories" } + +func (r *GithubRepository) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} + +// GithubRepositorySync is the per-project sync configuration for a linked +// GitHub repository. One row per (project, repository). +// Matches table "github_repository_syncs". +type GithubRepositorySync struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + RepositoryID uuid.UUID `gorm:"column:repository_id;type:uuid;not null" json:"repository_id"` + Credentials JSONMap `gorm:"type:jsonb;default:'{}';serializer:json" json:"-"` + ActorID uuid.UUID `gorm:"column:actor_id;type:uuid;not null" json:"actor_id"` + WorkspaceIntegrationID uuid.UUID `gorm:"column:workspace_integration_id;type:uuid;not null" json:"workspace_integration_id"` + LabelID *uuid.UUID `gorm:"column:label_id;type:uuid" json:"label_id,omitempty"` + ProjectID uuid.UUID `gorm:"column:project_id;type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"column:workspace_id;type:uuid;not null" json:"workspace_id"` + AutoLink bool `gorm:"column:auto_link;not null;default:true" json:"auto_link"` + AutoCloseOnMerge bool `gorm:"column:auto_close_on_merge;not null;default:true" json:"auto_close_on_merge"` + InProgressStateID *uuid.UUID `gorm:"column:in_progress_state_id;type:uuid" json:"in_progress_state_id,omitempty"` + DoneStateID *uuid.UUID `gorm:"column:done_state_id;type:uuid" json:"done_state_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` +} + +func (GithubRepositorySync) TableName() string { return "github_repository_syncs" } + +func (r *GithubRepositorySync) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} + +// GithubIssueSync links a GitHub PR (or issue) to a Devlane issue. +// We use kind='pull_request' for PRs (the Linear-style PR↔issue sync) and +// 'issue' if/when GH-issue ↔ Devlane-issue sync is added. +// Matches table "github_issue_syncs" (extended in 000003). +type GithubIssueSync struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + RepoIssueID int64 `gorm:"column:repo_issue_id;type:bigint;not null" json:"repo_issue_id"` + GithubIssueID int64 `gorm:"column:github_issue_id;type:bigint;not null" json:"github_issue_id"` + IssueURL string `gorm:"column:issue_url;type:text;not null" json:"issue_url"` + IssueID uuid.UUID `gorm:"column:issue_id;type:uuid;not null" json:"issue_id"` + RepositorySyncID uuid.UUID `gorm:"column:repository_sync_id;type:uuid;not null" json:"repository_sync_id"` + ProjectID uuid.UUID `gorm:"column:project_id;type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"column:workspace_id;type:uuid;not null" json:"workspace_id"` + Kind string `gorm:"column:kind;type:varchar(30);not null;default:'pull_request'" json:"kind"` + State string `gorm:"column:state;type:varchar(30);not null;default:'open'" json:"state"` + Title string `gorm:"column:title;type:varchar(1024)" json:"title,omitempty"` + Draft bool `gorm:"column:draft;not null;default:false" json:"draft"` + MergedAt *time.Time `gorm:"column:merged_at" json:"merged_at,omitempty"` + ClosedAt *time.Time `gorm:"column:closed_at" json:"closed_at,omitempty"` + AuthorLogin string `gorm:"column:author_login;type:varchar(255)" json:"author_login,omitempty"` + BaseBranch string `gorm:"column:base_branch;type:varchar(255)" json:"base_branch,omitempty"` + HeadBranch string `gorm:"column:head_branch;type:varchar(255)" json:"head_branch,omitempty"` + DetectionSource string `gorm:"column:detection_source;type:varchar(30)" json:"detection_source,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` +} + +func (GithubIssueSync) TableName() string { return "github_issue_syncs" } + +func (g *GithubIssueSync) BeforeCreate(tx *gorm.DB) error { + if g.ID == uuid.Nil { + g.ID = uuid.New() + } + return nil +} + +// GithubCommentSync links a GitHub comment to a Devlane comment (kept for +// future GH-issue ↔ Devlane-issue comment mirroring). +// Matches table "github_comment_syncs". +type GithubCommentSync struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + RepoCommentID int64 `gorm:"column:repo_comment_id;type:bigint;not null" json:"repo_comment_id"` + CommentID uuid.UUID `gorm:"column:comment_id;type:uuid;not null" json:"comment_id"` + IssueSyncID uuid.UUID `gorm:"column:issue_sync_id;type:uuid;not null" json:"issue_sync_id"` + ProjectID uuid.UUID `gorm:"column:project_id;type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"column:workspace_id;type:uuid;not null" json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` +} + +func (GithubCommentSync) TableName() string { return "github_comment_syncs" } + +func (g *GithubCommentSync) BeforeCreate(tx *gorm.DB) error { + if g.ID == uuid.Nil { + g.ID = uuid.New() + } + return nil +} + +// GithubWebhookEvent is an inbound webhook delivery (one row per X-GitHub-Delivery). +// Matches table "github_webhook_events". +type GithubWebhookEvent struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + DeliveryID string `gorm:"column:delivery_id;type:varchar(255);uniqueIndex;not null" json:"delivery_id"` + Event string `gorm:"column:event;type:varchar(64);not null" json:"event"` + Action string `gorm:"column:action;type:varchar(64)" json:"action,omitempty"` + InstallationID *int64 `gorm:"column:installation_id;type:bigint" json:"installation_id,omitempty"` + WorkspaceIntegrationID *uuid.UUID `gorm:"column:workspace_integration_id;type:uuid" json:"workspace_integration_id,omitempty"` + RepositoryFullName string `gorm:"column:repository_full_name;type:varchar(500)" json:"repository_full_name,omitempty"` + Payload JSONMap `gorm:"column:payload;type:jsonb;not null;serializer:json" json:"payload"` + Status string `gorm:"column:status;type:varchar(30);not null;default:'received'" json:"status"` + ErrorMessage string `gorm:"column:error;type:text" json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + ProcessedAt *time.Time `gorm:"column:processed_at" json:"processed_at,omitempty"` +} + +func (GithubWebhookEvent) TableName() string { return "github_webhook_events" } + +func (e *GithubWebhookEvent) BeforeCreate(tx *gorm.DB) error { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + return nil +} diff --git a/api/internal/model/issue_activity.go b/api/internal/model/issue_activity.go new file mode 100644 index 00000000..92e00cc8 --- /dev/null +++ b/api/internal/model/issue_activity.go @@ -0,0 +1,42 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// IssueActivity matches table "issue_activities". Stores field-change events +// (verb=updated, field=state_id, old_value=..., new_value=...) plus generic +// "created" / "deleted" verbs. +type IssueActivity struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + IssueID *uuid.UUID `gorm:"column:issue_id;type:uuid" json:"issue_id,omitempty"` + ProjectID uuid.UUID `gorm:"column:project_id;type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"column:workspace_id;type:uuid;not null" json:"workspace_id"` + Verb string `gorm:"type:varchar(255);not null;default:'created'" json:"verb"` + Field *string `gorm:"type:varchar(255)" json:"field,omitempty"` + OldValue *string `gorm:"column:old_value;type:text" json:"old_value,omitempty"` + NewValue *string `gorm:"column:new_value;type:text" json:"new_value,omitempty"` + Comment *string `gorm:"type:text" json:"comment,omitempty"` + IssueCommentID *uuid.UUID `gorm:"column:issue_comment_id;type:uuid" json:"issue_comment_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"column:created_by_id;type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"column:updated_by_id;type:uuid" json:"updated_by_id,omitempty"` + ActorID *uuid.UUID `gorm:"column:actor_id;type:uuid" json:"actor_id,omitempty"` + OldIdentifier *uuid.UUID `gorm:"column:old_identifier;type:uuid" json:"old_identifier,omitempty"` + NewIdentifier *uuid.UUID `gorm:"column:new_identifier;type:uuid" json:"new_identifier,omitempty"` + Epoch *float64 `gorm:"column:epoch" json:"epoch,omitempty"` +} + +func (IssueActivity) TableName() string { return "issue_activities" } + +func (a *IssueActivity) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + return nil +} diff --git a/api/internal/router/router.go b/api/internal/router/router.go index 46fc7258..7129d026 100644 --- a/api/internal/router/router.go +++ b/api/internal/router/router.go @@ -1,9 +1,11 @@ package router import ( + "context" "log/slog" "github.com/Devlaner/devlane/api/internal/auth" + gh "github.com/Devlaner/devlane/api/internal/github" "github.com/Devlaner/devlane/api/internal/handler" "github.com/Devlaner/devlane/api/internal/middleware" "github.com/Devlaner/devlane/api/internal/minio" @@ -74,6 +76,14 @@ func New(cfg Config) *gin.Engine { apiTokenStore := store.NewApiTokenStore(cfg.DB) userFavoriteStore := store.NewUserFavoriteStore(cfg.DB) + // Integration stores + integrationStore := store.NewIntegrationStore(cfg.DB) + workspaceIntegrationStore := store.NewWorkspaceIntegrationStore(cfg.DB) + githubRepoStore := store.NewGithubRepositoryStore(cfg.DB) + githubRepoSyncStore := store.NewGithubRepositorySyncStore(cfg.DB) + githubIssueSyncStore := store.NewGithubIssueSyncStore(cfg.DB) + githubWebhookEventStore := store.NewGithubWebhookEventStore(cfg.DB) + // Password reset tokens passwordResetTokenStore := store.NewPasswordResetTokenStore(cfg.DB) accountStore := store.NewAccountStore(cfg.DB) @@ -117,17 +127,64 @@ func New(cfg Config) *gin.Engine { projectSvc := service.NewProjectService(projectStore, projectInviteStore, workspaceStore, userStore) stateSvc := service.NewStateService(stateStore, projectStore, workspaceStore) labelSvc := service.NewLabelService(labelStore, projectStore, workspaceStore) + issueActivityStore := store.NewIssueActivityStore(cfg.DB) issueSvc := service.NewIssueService(issueStore, projectStore, workspaceStore) + issueSvc.SetActivityStore(issueActivityStore) cycleSvc := service.NewCycleService(cycleStore, projectStore, workspaceStore) moduleSvc := service.NewModuleService(moduleStore, projectStore, workspaceStore) issueViewSvc := service.NewIssueViewService(issueViewStore, projectStore, workspaceStore, userFavoriteStore) pageSvc := service.NewPageService(pageStore, projectStore, workspaceStore) notificationSvc := service.NewNotificationService(notificationStore, workspaceStore) + commentReactionStore := store.NewCommentReactionStore(cfg.DB) commentSvc := service.NewCommentService(commentStore, issueStore, projectStore, workspaceStore) + commentSvc.SetReactionStore(commentReactionStore) workspaceLinkSvc := service.NewWorkspaceLinkService(workspaceUserLinkStore, workspaceStore) stickySvc := service.NewStickyService(stickyStore, workspaceStore) recentVisitSvc := service.NewRecentVisitService(userRecentVisitStore, workspaceStore, issueStore, projectStore, pageStore) + // GitHub App: build the AppAuth + Client lazily from instance_settings. + // Failure here is non-fatal — endpoints that need it return 503 until + // the admin configures github_app. + var githubClient *gh.Client + if appAuth, err := service.LoadGitHubAppFromSettings(context.Background(), instanceSettingStore); err == nil && appAuth != nil { + githubClient = gh.NewClient(appAuth, nil) + } else if err != nil && cfg.Log != nil { + cfg.Log.Warn("github app not configured", "error", err) + } + + integrationSvc := service.NewIntegrationService( + integrationStore, workspaceIntegrationStore, workspaceStore, instanceSettingStore, githubClient, + ) + githubSyncSvc := service.NewGithubSyncService( + integrationSvc, workspaceIntegrationStore, githubRepoStore, githubRepoSyncStore, + githubIssueSyncStore, issueStore, workspaceStore, projectStore, + ) + githubEventSvc := service.NewGithubEventService( + cfg.Log, workspaceIntegrationStore, githubRepoStore, githubRepoSyncStore, integrationStore, + githubIssueSyncStore, githubWebhookEventStore, workspaceStore, projectStore, issueStore, stateStore, + commentStore, integrationSvc, + ) + integrationHandler := &handler.IntegrationHandler{ + Integration: integrationSvc, + GithubSync: githubSyncSvc, + GithubEvent: githubEventSvc, + Settings: instanceSettingStore, + AppBaseURL: appBaseURL, + APIPublicURL: cfg.APIPublicURL, + Log: cfg.Log, + } + + // Hot-reload integration clients when an admin saves new credentials so + // the new App auth takes effect without restarting the API. + instanceSettingsHandler.OnSectionUpdated = func(ctx context.Context, key string) { + if key != "github_app" { + return + } + if err := integrationSvc.ReloadGitHubClient(ctx); err != nil && cfg.Log != nil { + cfg.Log.Warn("github app reload after settings update failed", "error", err) + } + } + // Handlers workspaceHandler := &handler.WorkspaceHandler{ Workspace: workspaceSvc, @@ -233,6 +290,7 @@ func New(cfg Config) *gin.Engine { api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/assignees/", issueHandler.AddAssignee) api.PUT("/workspaces/:slug/projects/:projectId/issues/:pk/assignees/", issueHandler.ReplaceAssignees) api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/assignees/:assigneeId/", issueHandler.RemoveAssignee) + api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/activities/", issueHandler.ListActivities) api.GET("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.List) api.POST("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.Create) @@ -293,6 +351,33 @@ func New(cfg Config) *gin.Engine { api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/comments/", commentHandler.Create) api.PATCH("/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/", commentHandler.Update) api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/", commentHandler.Delete) + api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/reactions/", commentHandler.ListReactions) + api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/reactions/", commentHandler.AddReaction) + api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/comments/:commentId/reactions/:reaction/", commentHandler.RemoveReaction) + + // Integrations (workspace-level) + api.GET("/integrations/", integrationHandler.ListAvailable) + api.GET("/workspaces/:slug/integrations/", integrationHandler.ListInstalled) + api.DELETE("/workspaces/:slug/integrations/:provider/", integrationHandler.Uninstall) + + // GitHub-specific (workspace-level): list installation repos. + api.GET("/workspaces/:slug/integrations/github/repositories/", integrationHandler.GitHubListRepositories) + + // GitHub repo sync (project-scoped). + api.GET("/workspaces/:slug/projects/:projectId/integrations/github/sync/", integrationHandler.GitHubGetSync) + api.POST("/workspaces/:slug/projects/:projectId/integrations/github/sync/", integrationHandler.GitHubCreateSync) + api.PATCH("/workspaces/:slug/projects/:projectId/integrations/github/sync/", integrationHandler.GitHubUpdateSync) + api.DELETE("/workspaces/:slug/projects/:projectId/integrations/github/sync/", integrationHandler.GitHubDeleteSync) + + // GitHub PR ↔ issue links (per-issue, for the issue detail sidebar). + // :pk is the issue id (matches the existing /issues/:pk/ routes — Gin + // requires the same param name at the same path position). + api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/integrations/github/links/", integrationHandler.GitHubListIssueLinks) + api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/integrations/github/links/", integrationHandler.GitHubCreateIssueLink) + api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/integrations/github/links/:linkId/", integrationHandler.GitHubDeleteIssueLink) + + // Bulk PR summary for the issues list page badges. + api.GET("/workspaces/:slug/projects/:projectId/integrations/github/issue-summary/", integrationHandler.GitHubIssueSummary) } // Auth routes (no auth required) @@ -323,6 +408,16 @@ func New(cfg Config) *gin.Engine { authGroup.GET("/:provider/", oauthHandler.Initiate) authGroup.GET("/:provider/callback/", oauthHandler.Callback) + // GitHub App install flow (separate from OAuth user sign-in). Both + // require the user to be signed in so we can attach the installation to + // their workspace. + r.GET("/auth/github-app/install", middleware.RequireAuth(authSvc, cfg.Log), integrationHandler.GitHubInstallStart) + r.GET("/auth/github-app/callback", middleware.RequireAuth(authSvc, cfg.Log), integrationHandler.GitHubInstallCallback) + + // GitHub webhook receiver — public; HMAC-signature-verified. + r.POST("/webhooks/github", integrationHandler.GitHubWebhook) + r.POST("/webhooks/github/", integrationHandler.GitHubWebhook) + // Legacy /api/v1 v1 := r.Group("/api/v1") v1.Use(middleware.RequireAuth(authSvc, cfg.Log)) diff --git a/api/internal/service/comment.go b/api/internal/service/comment.go index 14470dcd..2f1f07b0 100644 --- a/api/internal/service/comment.go +++ b/api/internal/service/comment.go @@ -13,16 +13,20 @@ var ErrCommentNotFound = errors.New("comment not found") // CommentService handles issue comment business logic. type CommentService struct { - cs *store.CommentStore - is *store.IssueStore - ps *store.ProjectStore - ws *store.WorkspaceStore + cs *store.CommentStore + is *store.IssueStore + ps *store.ProjectStore + ws *store.WorkspaceStore + reactions *store.CommentReactionStore // optional — set via SetReactionStore } func NewCommentService(cs *store.CommentStore, is *store.IssueStore, ps *store.ProjectStore, ws *store.WorkspaceStore) *CommentService { return &CommentService{cs: cs, is: is, ps: ps, ws: ws} } +// SetReactionStore wires per-comment reactions support. Optional. +func (s *CommentService) SetReactionStore(r *store.CommentReactionStore) { s.reactions = r } + func (s *CommentService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error { wrk, err := s.ws.GetBySlug(ctx, workspaceSlug) if err != nil { @@ -50,7 +54,7 @@ func (s *CommentService) List(ctx context.Context, workspaceSlug string, project return s.cs.ListByIssueID(ctx, issueID) } -func (s *CommentService) Create(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, comment string) (*model.IssueComment, error) { +func (s *CommentService) Create(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, comment, access string) (*model.IssueComment, error) { if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { return nil, err } @@ -59,11 +63,18 @@ func (s *CommentService) Create(ctx context.Context, workspaceSlug string, proje if err != nil || issue.ProjectID != projectID { return nil, ErrCommentNotFound } + if access == "" { + access = "INTERNAL" + } + if access != "INTERNAL" && access != "EXTERNAL" { + access = "INTERNAL" + } c := &model.IssueComment{ IssueID: issueID, ProjectID: projectID, WorkspaceID: wrk.ID, Comment: comment, + Access: access, CreatedByID: &userID, } if err := s.cs.Create(ctx, c); err != nil { @@ -111,3 +122,50 @@ func (s *CommentService) Delete(ctx context.Context, workspaceSlug string, proje } return s.cs.Delete(ctx, commentID) } + +// ListReactions returns all reactions on a comment after auth-checking. +func (s *CommentService) ListReactions(ctx context.Context, workspaceSlug string, projectID, commentID uuid.UUID, userID uuid.UUID) ([]model.CommentReaction, error) { + if s.reactions == nil { + return []model.CommentReaction{}, nil + } + if _, err := s.Get(ctx, workspaceSlug, projectID, commentID, userID); err != nil { + return nil, err + } + return s.reactions.ListByCommentID(ctx, commentID) +} + +// AddReaction toggles a user's reaction on (idempotent — duplicates rejected +// by the DB unique constraint, treated as no-op). +func (s *CommentService) AddReaction(ctx context.Context, workspaceSlug string, projectID, commentID uuid.UUID, userID uuid.UUID, emoji string) (*model.CommentReaction, error) { + if s.reactions == nil { + return nil, errors.New("reactions store is not configured") + } + c, err := s.Get(ctx, workspaceSlug, projectID, commentID, userID) + if err != nil { + return nil, err + } + r := &model.CommentReaction{ + CommentID: c.ID, + Reaction: emoji, + ActorID: userID, + ProjectID: c.ProjectID, + WorkspaceID: c.WorkspaceID, + } + if err := s.reactions.Add(ctx, r); err != nil { + // Unique-constraint violation = already reacted, return existing row. + // We don't bother fetching it; caller can refetch the list. + return nil, err + } + return r, nil +} + +// RemoveReaction deletes a user's reaction. +func (s *CommentService) RemoveReaction(ctx context.Context, workspaceSlug string, projectID, commentID uuid.UUID, userID uuid.UUID, emoji string) error { + if s.reactions == nil { + return errors.New("reactions store is not configured") + } + if _, err := s.Get(ctx, workspaceSlug, projectID, commentID, userID); err != nil { + return err + } + return s.reactions.Remove(ctx, commentID, userID, emoji) +} diff --git a/api/internal/service/github_events.go b/api/internal/service/github_events.go new file mode 100644 index 00000000..334bd708 --- /dev/null +++ b/api/internal/service/github_events.go @@ -0,0 +1,578 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + + gh "github.com/Devlaner/devlane/api/internal/github" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" +) + +// GithubEventService processes inbound webhook events: it owns the side-effects +// that turn a GitHub event into Devlane state changes (issue activity comments, +// PR↔issue link upserts, state transitions on merge). +type GithubEventService struct { + log *slog.Logger + + wis *store.WorkspaceIntegrationStore + repo *store.GithubRepositoryStore + rs *store.GithubRepositorySyncStore + is *store.IntegrationStore + issues *store.GithubIssueSyncStore + events *store.GithubWebhookEventStore + ws *store.WorkspaceStore + ps *store.ProjectStore + issue *store.IssueStore + state *store.StateStore + comment *store.CommentStore + intSvc *IntegrationService +} + +// NewGithubEventService wires the dependencies needed for event processing. +func NewGithubEventService( + log *slog.Logger, + wis *store.WorkspaceIntegrationStore, + repo *store.GithubRepositoryStore, + rs *store.GithubRepositorySyncStore, + intStore *store.IntegrationStore, + issues *store.GithubIssueSyncStore, + events *store.GithubWebhookEventStore, + ws *store.WorkspaceStore, + ps *store.ProjectStore, + issue *store.IssueStore, + state *store.StateStore, + comment *store.CommentStore, + intSvc *IntegrationService, +) *GithubEventService { + return &GithubEventService{ + log: log, wis: wis, repo: repo, rs: rs, is: intStore, issues: issues, + events: events, ws: ws, ps: ps, issue: issue, state: state, comment: comment, intSvc: intSvc, + } +} + +// HandleWebhook is the entry point called by the HTTP handler after signature +// verification. event is the X-GitHub-Event header value, deliveryID the +// X-GitHub-Delivery header value (for idempotency), payload the raw body. +// +// Returns nil to ack the delivery; non-nil errors are logged and recorded but +// still result in a 200 response — webhook senders should never see internal +// failures. (GitHub will not retry on 200, but our github_webhook_events row +// captures the failure for ops to triage.) +func (s *GithubEventService) HandleWebhook(ctx context.Context, event, deliveryID string, payload []byte) error { + if deliveryID == "" { + return errors.New("missing X-GitHub-Delivery header") + } + // Idempotency: short-circuit if we've already logged this delivery. + if exists, _ := s.events.ExistsByDeliveryID(ctx, deliveryID); exists { + s.logger().Debug("github webhook delivery already processed", "delivery_id", deliveryID, "event", event) + return nil + } + + envelope := struct { + Action string `json:"action,omitempty"` + Installation *gh.InstallationLite `json:"installation,omitempty"` + Repository *gh.RepositoryLite `json:"repository,omitempty"` + }{} + _ = json.Unmarshal(payload, &envelope) + + var installationID *int64 + if envelope.Installation != nil { + id := envelope.Installation.ID + installationID = &id + } + repoFullName := "" + if envelope.Repository != nil { + repoFullName = envelope.Repository.FullName + } + + logRow := &model.GithubWebhookEvent{ + DeliveryID: deliveryID, + Event: event, + Action: envelope.Action, + InstallationID: installationID, + RepositoryFullName: repoFullName, + Payload: payloadToJSONMap(payload), + Status: "received", + } + if err := s.events.Create(ctx, logRow); err != nil { + s.logger().Error("failed to record github webhook event", "delivery_id", deliveryID, "error", err) + } + + dispatchErr := s.dispatch(ctx, event, payload, envelope.Action) + status := "processed" + errMsg := "" + if dispatchErr != nil { + status = "error" + errMsg = dispatchErr.Error() + s.logger().Warn("github webhook handler failed", "event", event, "delivery_id", deliveryID, "error", dispatchErr) + } + _ = s.events.MarkProcessed(ctx, logRow.ID, status, errMsg) + return dispatchErr +} + +// dispatch is the per-event router; isolated from HandleWebhook so logging +// happens in one place. +func (s *GithubEventService) dispatch(ctx context.Context, event string, payload []byte, action string) error { + switch event { + case gh.EventPing: + return nil + case gh.EventInstallation: + return s.handleInstallation(ctx, payload) + case gh.EventInstallationRepositories: + return s.handleInstallationRepositories(ctx, payload) + case gh.EventPullRequest: + return s.handlePullRequest(ctx, payload, action) + case gh.EventIssueComment: + return s.handleIssueComment(ctx, payload, action) + case gh.EventPush: + return s.handlePush(ctx, payload) + default: + // Unknown / unhandled events — accepted but no-op. + return nil + } +} + +// --------------------------------------------------------------------------- +// installation lifecycle +// --------------------------------------------------------------------------- + +func (s *GithubEventService) handleInstallation(ctx context.Context, payload []byte) error { + var ev gh.InstallationEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return err + } + wi, err := s.wis.GetByInstallationID(ctx, ev.Installation.ID) + if err != nil { + // Not yet linked to a workspace — that's fine for the "created" case; + // the user may not have completed the OAuth state exchange yet. We + // silently ignore it. + return nil + } + switch ev.Action { + case "created", "new_permissions_accepted": + // Hydrate account fields if blank. + changed := false + if wi.AccountLogin == "" && ev.Installation.Account.Login != "" { + wi.AccountLogin = ev.Installation.Account.Login + changed = true + } + if wi.AccountType == "" && ev.Installation.Account.Type != "" { + wi.AccountType = ev.Installation.Account.Type + changed = true + } + if wi.AccountAvatarURL == "" && ev.Installation.Account.AvatarURL != "" { + wi.AccountAvatarURL = ev.Installation.Account.AvatarURL + changed = true + } + if wi.SuspendedAt != nil { + wi.SuspendedAt = nil + changed = true + } + if changed { + return s.wis.Update(ctx, wi) + } + return nil + case "suspend": + return s.wis.MarkSuspended(ctx, wi.ID, true) + case "unsuspend": + return s.wis.MarkSuspended(ctx, wi.ID, false) + case "deleted": + if s.intSvc != nil && s.intSvc.GitHubClient() != nil { + s.intSvc.GitHubClient().InvalidateInstallation(ev.Installation.ID) + } + return s.wis.Delete(ctx, wi.ID) + } + return nil +} + +func (s *GithubEventService) handleInstallationRepositories(ctx context.Context, payload []byte) error { + var ev gh.InstallationRepositoriesEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return err + } + // We don't auto-prune syncs on "removed" — admins might have re-enabled + // after a typo. Logging only is the safe default. + s.logger().Info("github installation repositories changed", + "installation_id", ev.Installation.ID, + "added", len(ev.RepositoriesAdded), "removed", len(ev.RepositoriesRemoved)) + return nil +} + +// --------------------------------------------------------------------------- +// pull_request +// --------------------------------------------------------------------------- + +func (s *GithubEventService) handlePullRequest(ctx context.Context, payload []byte, action string) error { + var ev gh.PullRequestEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return err + } + if ev.Installation == nil { + return nil + } + syncs, err := s.findSyncsForRepo(ctx, ev.Installation.ID, ev.Repository.ID) + if err != nil || len(syncs) == 0 { + return err + } + + pr := ev.PullRequest + state := pr.EffectiveState() + refs := gh.MergeRefs( + gh.ExtractRefs(pr.Title), + gh.ExtractRefs(pr.Body), + gh.ExtractRefsFromBranch(pr.Head.Ref), + ) + + // For each project that's linked to this repo, resolve refs and update. + for _, sync := range syncs { + w, err := s.ws.GetByID(ctx, sync.WorkspaceID) + if err != nil { + continue + } + for _, ref := range refs { + project, err := s.ps.GetByWorkspaceAndIdentifier(ctx, w.ID, ref.Identifier) + if err != nil { + continue + } + // We sync only PRs that target the project this sync row owns. + // If the ref's project doesn't match this sync's project, skip — + // another project's sync row will handle it. + if project.ID != sync.ProjectID { + continue + } + issue, err := s.issue.GetByProjectAndSequence(ctx, project.ID, ref.Number) + if err != nil { + continue + } + + // Upsert the link row. + detection := refDetectionSource(pr, ref) + link := &model.GithubIssueSync{ + RepoIssueID: int64(pr.Number), + GithubIssueID: pr.ID, + IssueURL: pr.HTMLURL, + IssueID: issue.ID, + RepositorySyncID: sync.ID, + ProjectID: sync.ProjectID, + WorkspaceID: sync.WorkspaceID, + Kind: "pull_request", + State: state, + Title: pr.Title, + Draft: pr.Draft, + MergedAt: pr.MergedAt, + ClosedAt: pr.ClosedAt, + AuthorLogin: pr.User.Login, + BaseBranch: pr.Base.Ref, + HeadBranch: pr.Head.Ref, + DetectionSource: detection, + } + if _, err := s.issues.UpsertByPRAndIssue(ctx, link); err != nil { + s.logger().Warn("github sync upsert failed", "error", err, "issue_id", issue.ID) + continue + } + + // Activity comment + state transition based on PR action. + s.applyPRSideEffects(ctx, &sync, issue, pr, action, ref.Closes) + } + } + return nil +} + +// applyPRSideEffects posts an activity comment on the Devlane issue and, on +// merge, optionally moves the issue to the configured "done" state. +func (s *GithubEventService) applyPRSideEffects(ctx context.Context, sync *model.GithubRepositorySync, issue *model.Issue, pr gh.PullRequest, action string, closes bool) { + body := s.formatPRComment(pr, action) + if body != "" { + c := &model.IssueComment{ + IssueID: issue.ID, + ProjectID: issue.ProjectID, + WorkspaceID: issue.WorkspaceID, + Comment: body, + } + if err := s.comment.Create(ctx, c); err != nil { + s.logger().Warn("github sync: failed to post activity comment", "error", err, "issue_id", issue.ID) + } + } + + // State transitions: only when the integration is configured to do so AND + // the PR is closing the issue (closing keyword in title/body). + if !sync.AutoCloseOnMerge { + return + } + switch { + case (action == "closed" && pr.Merged) && closes && sync.DoneStateID != nil: + issue.StateID = sync.DoneStateID + if err := s.issue.Update(ctx, issue); err != nil { + s.logger().Warn("github sync: failed to close issue", "error", err, "issue_id", issue.ID) + } + case (action == "opened" || action == "reopened" || action == "ready_for_review") && sync.InProgressStateID != nil: + // Only move to in_progress if the issue isn't already done. + issue.StateID = sync.InProgressStateID + if err := s.issue.Update(ctx, issue); err != nil { + s.logger().Warn("github sync: failed to mark in-progress", "error", err, "issue_id", issue.ID) + } + } +} + +// formatPRComment renders the bot comment posted to the Devlane issue when a +// PR transitions. Output is HTML — the comment-renderer on the frontend uses +// dangerouslySetInnerHTML, so plain text and markdown render literally. +func (s *GithubEventService) formatPRComment(pr gh.PullRequest, action string) string { + verb := "" + switch { + case action == "opened" && pr.Draft: + verb = "opened a draft pull request" + case action == "opened": + verb = "opened pull request" + case action == "ready_for_review": + verb = "marked pull request ready for review" + case action == "converted_to_draft": + verb = "converted pull request to draft" + case action == "reopened": + verb = "reopened pull request" + case action == "closed" && pr.Merged: + verb = "merged pull request" + case action == "closed": + verb = "closed pull request" + case action == "edited": + // Skip edit notifications — they're noisy. + return "" + default: + return "" + } + author := pr.User.Login + if author == "" { + author = "Someone" + } + return fmt.Sprintf( + `

@%s %s #%d %s

`, + htmlEscape(author), + htmlEscape(verb), + htmlAttrEscape(pr.HTMLURL), + pr.Number, + htmlEscape(pr.Title), + ) +} + +// htmlEscape replaces &, <, >, and quotes so user-provided strings can't break +// the surrounding HTML. Used for both text content and attribute values. +func htmlEscape(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return r.Replace(s) +} + +// htmlAttrEscape is htmlEscape plus a stricter pass for URLs — drops control +// chars and surrounding whitespace. +func htmlAttrEscape(s string) string { + return htmlEscape(strings.TrimSpace(s)) +} + +// refDetectionSource returns "title" | "body" | "branch" depending on where the +// ref was strongest. Best-effort categorization for analytics. +func refDetectionSource(pr gh.PullRequest, ref gh.IssueRef) string { + id := ref.String() + if strings.Contains(strings.ToUpper(pr.Title), id) { + return "title" + } + if strings.Contains(strings.ToUpper(pr.Body), id) { + return "body" + } + if strings.Contains(strings.ToUpper(pr.Head.Ref), id) { + return "branch" + } + return "unknown" +} + +// --------------------------------------------------------------------------- +// push +// --------------------------------------------------------------------------- + +func (s *GithubEventService) handlePush(ctx context.Context, payload []byte) error { + var ev gh.PushEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return err + } + if ev.Installation == nil || ev.Deleted { + return nil + } + syncs, err := s.findSyncsForRepo(ctx, ev.Installation.ID, ev.Repository.ID) + if err != nil || len(syncs) == 0 { + return err + } + + branch := strings.TrimPrefix(ev.Ref, "refs/heads/") + branchRefs := gh.ExtractRefsFromBranch(branch) + commitRefs := []gh.IssueRef{} + for _, c := range ev.Commits { + commitRefs = gh.MergeRefs(commitRefs, gh.ExtractRefs(c.Message)) + } + all := gh.MergeRefs(branchRefs, commitRefs) + if len(all) == 0 { + return nil + } + + for _, sync := range syncs { + w, err := s.ws.GetByID(ctx, sync.WorkspaceID) + if err != nil { + continue + } + for _, ref := range all { + p, err := s.ps.GetByWorkspaceAndIdentifier(ctx, w.ID, ref.Identifier) + if err != nil || p.ID != sync.ProjectID { + continue + } + issue, err := s.issue.GetByProjectAndSequence(ctx, p.ID, ref.Number) + if err != nil { + continue + } + body := s.formatPushComment(ev, branch, len(ev.Commits)) + if body == "" { + continue + } + if err := s.comment.Create(ctx, &model.IssueComment{ + IssueID: issue.ID, + ProjectID: issue.ProjectID, + WorkspaceID: issue.WorkspaceID, + Comment: body, + }); err != nil { + s.logger().Warn("github sync: failed to post push comment", "error", err, "issue_id", issue.ID) + } + } + } + return nil +} + +func (s *GithubEventService) formatPushComment(ev gh.PushEvent, branch string, n int) string { + if n == 0 || ev.Created { + return "" + } + plural := "" + if n != 1 { + plural = "s" + } + author := ev.Sender.Login + if author == "" { + author = "Someone" + } + return fmt.Sprintf( + `

@%s pushed %d commit%s to %s.

`, + htmlEscape(author), n, plural, htmlEscape(branch), + ) +} + +// --------------------------------------------------------------------------- +// issue_comment (PR comments) +// --------------------------------------------------------------------------- + +func (s *GithubEventService) handleIssueComment(ctx context.Context, payload []byte, action string) error { + if action != "created" { + return nil + } + var ev gh.IssueCommentEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return err + } + if ev.Installation == nil { + return nil + } + // Only mirror PR comments — not GH-issue comments (we don't yet sync GH issues). + if !ev.Issue.IsPullRequest() { + return nil + } + syncs, err := s.findSyncsForRepo(ctx, ev.Installation.ID, ev.Repository.ID) + if err != nil || len(syncs) == 0 { + return err + } + for _, sync := range syncs { + links, err := s.issues.ListByPR(ctx, sync.ID, int64(ev.Issue.Number), "pull_request") + if err != nil || len(links) == 0 { + continue + } + body := s.formatIssueCommentMirror(ev) + if body == "" { + continue + } + for _, link := range links { + if err := s.comment.Create(ctx, &model.IssueComment{ + IssueID: link.IssueID, + ProjectID: link.ProjectID, + WorkspaceID: link.WorkspaceID, + Comment: body, + }); err != nil { + s.logger().Warn("github sync: failed to mirror PR comment", "error", err, "issue_id", link.IssueID) + } + } + } + return nil +} + +func (s *GithubEventService) formatIssueCommentMirror(ev gh.IssueCommentEvent) string { + author := ev.Comment.User.Login + if author == "" { + author = "Someone" + } + excerpt := ev.Comment.Body + const max = 280 + if len(excerpt) > max { + excerpt = excerpt[:max] + "…" + } + excerpt = strings.ReplaceAll(excerpt, "\n", " ") + return fmt.Sprintf( + `

💬 @%s commented on #%d: %s

`, + htmlEscape(author), + htmlAttrEscape(ev.Comment.HTMLURL), + ev.Issue.Number, + htmlEscape(excerpt), + ) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// findSyncsForRepo returns every project sync row that should react to events +// for the given (installation, github repo). Workspace is determined via the +// installation_id, then we narrow to project syncs that point at this repo. +func (s *GithubEventService) findSyncsForRepo(ctx context.Context, installationID int64, repoID int64) ([]model.GithubRepositorySync, error) { + wi, err := s.wis.GetByInstallationID(ctx, installationID) + if err != nil { + // Unknown installation — drop silently. + return nil, nil + } + if wi.SuspendedAt != nil { + return nil, nil + } + return s.rs.ListByGithubRepoID(ctx, wi.WorkspaceID, repoID) +} + +func (s *GithubEventService) logger() *slog.Logger { + if s.log != nil { + return s.log + } + return slog.Default() +} + +// payloadToJSONMap parses a webhook body into a JSONMap for db storage. +// On parse failure we still record the raw bytes under "_raw" so triage is possible. +func payloadToJSONMap(b []byte) model.JSONMap { + out := model.JSONMap{} + if err := json.Unmarshal(b, &out); err != nil { + return model.JSONMap{"_raw": string(b)} + } + return out +} + +// _ = uuid keeps import live in case future helpers need it. +var _ = uuid.Nil diff --git a/api/internal/service/github_sync.go b/api/internal/service/github_sync.go new file mode 100644 index 00000000..c900b9cc --- /dev/null +++ b/api/internal/service/github_sync.go @@ -0,0 +1,354 @@ +package service + +import ( + "context" + "errors" + "regexp" + "strconv" + "strings" + + gh "github.com/Devlaner/devlane/api/internal/github" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" +) + +var ( + ErrRepoSyncNotFound = errors.New("repository sync not found") + ErrRepoSyncExists = errors.New("project already linked to a github repository") + ErrInvalidPRURL = errors.New("invalid github pull request url") + ErrPRRepoMismatch = errors.New("pull request belongs to a different repository than the one linked to this project") + ErrIssueLinkNotFound = errors.New("pull request link not found") +) + +// prURLRegex matches https://github.com/owner/repo/pull/123 (with optional trailing slash or path). +var prURLRegex = regexp.MustCompile(`^https?://github\.com/([^/\s]+)/([^/\s]+)/pull/(\d+)`) + +// GithubSyncService manages per-project GitHub repository links. +type GithubSyncService struct { + is *IntegrationService + wis *store.WorkspaceIntegrationStore + repo *store.GithubRepositoryStore + rs *store.GithubRepositorySyncStore + issues *store.GithubIssueSyncStore + issue *store.IssueStore + ws *store.WorkspaceStore + ps *store.ProjectStore +} + +func NewGithubSyncService( + is *IntegrationService, + wis *store.WorkspaceIntegrationStore, + repo *store.GithubRepositoryStore, + rs *store.GithubRepositorySyncStore, + issues *store.GithubIssueSyncStore, + issue *store.IssueStore, + ws *store.WorkspaceStore, + ps *store.ProjectStore, +) *GithubSyncService { + return &GithubSyncService{ + is: is, wis: wis, repo: repo, rs: rs, issues: issues, issue: issue, ws: ws, ps: ps, + } +} + +// ListRepositories proxies the installation's repos via the GitHub API. +// Pagination follows GitHub's `page` (1-based) / `per_page` (≤100). +func (s *GithubSyncService) ListRepositories(ctx context.Context, workspaceSlug string, userID uuid.UUID, page, perPage int) ([]gh.Repository, int, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, 0, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, 0, ErrWorkspaceForbidden + } + wi, err := s.wis.GetByWorkspaceAndProvider(ctx, w.ID, "github") + if err != nil || wi.InstallationID == nil { + return nil, 0, ErrIntegrationNotFound + } + client := s.is.GitHubClient() + if client == nil { + return nil, 0, ErrGitHubAppNotConfigured + } + return client.ListInstallationRepositories(ctx, *wi.InstallationID, page, perPage) +} + +// LinkRequest is the input for creating a sync. +type LinkRequest struct { + GithubRepositoryID int64 // required (GitHub's numeric repo ID) + Owner string // required + Name string // required + URL string // optional (HTML URL for display) +} + +// CreateSync links a GitHub repository to a Devlane project. Errors with +// ErrRepoSyncExists if the project already has a sync. We don't enforce +// repository uniqueness across projects within a workspace — multiple projects +// in the same workspace may legitimately track the same repo. +func (s *GithubSyncService) CreateSync(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, req LinkRequest) (*model.GithubRepositorySync, *model.GithubRepository, error) { + if req.GithubRepositoryID <= 0 || strings.TrimSpace(req.Owner) == "" || strings.TrimSpace(req.Name) == "" { + return nil, nil, errors.New("github_repository_id, owner, name are required") + } + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, nil, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, nil, ErrWorkspaceForbidden + } + inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, w.ID) + if !inWorkspace { + return nil, nil, ErrProjectNotFound + } + wi, err := s.wis.GetByWorkspaceAndProvider(ctx, w.ID, "github") + if err != nil { + return nil, nil, ErrIntegrationNotFound + } + if existing, err := s.rs.GetByProjectID(ctx, projectID); err == nil && existing != nil { + return nil, nil, ErrRepoSyncExists + } + repo := &model.GithubRepository{ + Name: req.Name, + Owner: req.Owner, + URL: req.URL, + RepositoryID: req.GithubRepositoryID, + ProjectID: projectID, + WorkspaceID: w.ID, + Config: model.JSONMap{}, + CreatedByID: &userID, + } + if err := s.repo.Create(ctx, repo); err != nil { + return nil, nil, err + } + sync := &model.GithubRepositorySync{ + RepositoryID: repo.ID, + ActorID: userID, + WorkspaceIntegrationID: wi.ID, + ProjectID: projectID, + WorkspaceID: w.ID, + AutoLink: true, + AutoCloseOnMerge: true, + Credentials: model.JSONMap{}, + CreatedByID: &userID, + } + if err := s.rs.Create(ctx, sync); err != nil { + // Best-effort rollback of the repo row. + _ = s.repo.Delete(ctx, repo.ID) + return nil, nil, err + } + return sync, repo, nil +} + +// UpdateSync updates per-repo settings (auto_link, auto_close_on_merge, state map). +func (s *GithubSyncService) UpdateSync(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, autoLink, autoCloseOnMerge *bool, inProgressStateID, doneStateID *uuid.UUID) (*model.GithubRepositorySync, error) { + sync, _, err := s.GetByProject(ctx, workspaceSlug, projectID, userID) + if err != nil { + return nil, err + } + if autoLink != nil { + sync.AutoLink = *autoLink + } + if autoCloseOnMerge != nil { + sync.AutoCloseOnMerge = *autoCloseOnMerge + } + if inProgressStateID != nil { + if *inProgressStateID == uuid.Nil { + sync.InProgressStateID = nil + } else { + sync.InProgressStateID = inProgressStateID + } + } + if doneStateID != nil { + if *doneStateID == uuid.Nil { + sync.DoneStateID = nil + } else { + sync.DoneStateID = doneStateID + } + } + sync.UpdatedByID = &userID + if err := s.rs.Update(ctx, sync); err != nil { + return nil, err + } + return sync, nil +} + +// GetByProject returns the project's sync row along with its github_repositories row. +func (s *GithubSyncService) GetByProject(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) (*model.GithubRepositorySync, *model.GithubRepository, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, nil, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, nil, ErrWorkspaceForbidden + } + inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, w.ID) + if !inWorkspace { + return nil, nil, ErrProjectNotFound + } + sync, err := s.rs.GetByProjectID(ctx, projectID) + if err != nil { + return nil, nil, ErrRepoSyncNotFound + } + repo, err := s.repo.GetByID(ctx, sync.RepositoryID) + if err != nil { + return sync, nil, nil + } + return sync, repo, nil +} + +// DeleteSync removes the project ↔ repo link. +func (s *GithubSyncService) DeleteSync(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error { + sync, repo, err := s.GetByProject(ctx, workspaceSlug, projectID, userID) + if err != nil { + return err + } + if err := s.rs.Delete(ctx, sync.ID); err != nil { + return err + } + if repo != nil { + _ = s.repo.Delete(ctx, repo.ID) + } + return nil +} + +// --------------------------------------------------------------------------- +// Per-issue PR links (issue detail page sidebar) +// --------------------------------------------------------------------------- + +// ListLinksForIssue returns all PR links attached to a Devlane issue. +func (s *GithubSyncService) ListLinksForIssue(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID) ([]model.GithubIssueSync, error) { + if err := s.ensureIssueAccess(ctx, workspaceSlug, projectID, issueID, userID); err != nil { + return nil, err + } + return s.issues.ListByIssueID(ctx, issueID) +} + +// CreateLinkFromURL parses a GitHub PR URL, fetches the PR via the App +// installation, and links it to the Devlane issue. The PR's repo must match +// the project's currently-linked repo. +func (s *GithubSyncService) CreateLinkFromURL(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, prURL string) (*model.GithubIssueSync, error) { + if err := s.ensureIssueAccess(ctx, workspaceSlug, projectID, issueID, userID); err != nil { + return nil, err + } + owner, repoName, number, err := parsePRURL(prURL) + if err != nil { + return nil, err + } + sync, err := s.rs.GetByProjectID(ctx, projectID) + if err != nil { + return nil, ErrRepoSyncNotFound + } + repo, err := s.repo.GetByID(ctx, sync.RepositoryID) + if err != nil { + return nil, ErrRepoSyncNotFound + } + if !strings.EqualFold(repo.Owner, owner) || !strings.EqualFold(repo.Name, repoName) { + return nil, ErrPRRepoMismatch + } + wi, err := s.wis.GetByID(ctx, sync.WorkspaceIntegrationID) + if err != nil || wi.InstallationID == nil { + return nil, ErrIntegrationNotFound + } + client := s.is.GitHubClient() + if client == nil { + return nil, ErrGitHubAppNotConfigured + } + pr, err := client.GetPullRequest(ctx, *wi.InstallationID, owner, repoName, number) + if err != nil { + return nil, err + } + + link := &model.GithubIssueSync{ + RepoIssueID: int64(pr.Number), + GithubIssueID: pr.ID, + IssueURL: pr.HTMLURL, + IssueID: issueID, + RepositorySyncID: sync.ID, + ProjectID: sync.ProjectID, + WorkspaceID: sync.WorkspaceID, + Kind: "pull_request", + State: pr.EffectiveState(), + Title: pr.Title, + Draft: pr.Draft, + MergedAt: pr.MergedAt, + ClosedAt: pr.ClosedAt, + AuthorLogin: pr.User.Login, + BaseBranch: pr.Base.Ref, + HeadBranch: pr.Head.Ref, + DetectionSource: "manual", + CreatedByID: &userID, + } + return s.issues.UpsertByPRAndIssue(ctx, link) +} + +// DeleteLinkForIssue removes one PR↔issue link. +func (s *GithubSyncService) DeleteLinkForIssue(ctx context.Context, workspaceSlug string, projectID, issueID, linkID uuid.UUID, userID uuid.UUID) error { + if err := s.ensureIssueAccess(ctx, workspaceSlug, projectID, issueID, userID); err != nil { + return err + } + link, err := s.issues.GetByID(ctx, linkID) + if err != nil { + return ErrIssueLinkNotFound + } + // Make sure the link actually belongs to this issue/project — defensive. + if link.IssueID != issueID || link.ProjectID != projectID { + return ErrIssueLinkNotFound + } + return s.issues.Delete(ctx, linkID) +} + +// IssueSummaryForProject returns aggregate PR counts per issue ID, scoped to +// a single project. Used by the issues list page to render badges. +func (s *GithubSyncService) IssueSummaryForProject(ctx context.Context, workspaceSlug string, projectID uuid.UUID, issueIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]store.IssueSummary, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, ErrWorkspaceForbidden + } + inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, w.ID) + if !inWorkspace { + return nil, ErrProjectNotFound + } + return s.issues.SummaryForIssues(ctx, projectID, issueIDs) +} + +// ensureIssueAccess validates that the requester is a workspace member and +// that the issue belongs to the project (which belongs to the workspace). +func (s *GithubSyncService) ensureIssueAccess(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID) error { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return ErrWorkspaceForbidden + } + inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, w.ID) + if !inWorkspace { + return ErrProjectNotFound + } + issue, err := s.issue.GetByID(ctx, issueID) + if err != nil || issue.ProjectID != projectID { + return ErrProjectNotFound + } + return nil +} + +// parsePRURL extracts owner/repo/number from a GitHub PR URL. +func parsePRURL(s string) (owner, repo string, number int, err error) { + s = strings.TrimSpace(s) + m := prURLRegex.FindStringSubmatch(s) + if m == nil { + return "", "", 0, ErrInvalidPRURL + } + n, convErr := strconv.Atoi(m[3]) + if convErr != nil || n <= 0 { + return "", "", 0, ErrInvalidPRURL + } + return m[1], m[2], n, nil +} diff --git a/api/internal/service/integration.go b/api/internal/service/integration.go new file mode 100644 index 00000000..da780519 --- /dev/null +++ b/api/internal/service/integration.go @@ -0,0 +1,298 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/Devlaner/devlane/api/internal/crypto" + "github.com/Devlaner/devlane/api/internal/github" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" +) + +var ( + ErrIntegrationNotFound = errors.New("integration not found") + ErrIntegrationAlreadyInstalled = errors.New("integration already installed in this workspace") + ErrGitHubAppNotConfigured = errors.New("github app is not configured") + ErrInstallationFetch = errors.New("failed to fetch github installation") +) + +// IntegrationService coordinates the generic Integration / WorkspaceIntegration +// tables. GitHub-specific install logic lives here because GitHub is currently +// the only provider, but the structure allows other providers to slot in. +type IntegrationService struct { + is *store.IntegrationStore + wis *store.WorkspaceIntegrationStore + ws *store.WorkspaceStore + set *store.InstanceSettingStore + + githubClient *github.Client +} + +// NewIntegrationService builds the service. githubClient may be nil — methods +// that need it will return ErrGitHubAppNotConfigured. +func NewIntegrationService( + is *store.IntegrationStore, + wis *store.WorkspaceIntegrationStore, + ws *store.WorkspaceStore, + set *store.InstanceSettingStore, + githubClient *github.Client, +) *IntegrationService { + return &IntegrationService{is: is, wis: wis, ws: ws, set: set, githubClient: githubClient} +} + +// SetGitHubClient replaces the cached client (called when admin updates +// github_app settings — we rebuild the AppAuth on every change). +func (s *IntegrationService) SetGitHubClient(c *github.Client) { s.githubClient = c } + +// GitHubClient exposes the cached client (used by webhook handler). +func (s *IntegrationService) GitHubClient() *github.Client { return s.githubClient } + +// ReloadGitHubClient re-reads the github_app instance settings and rebuilds +// the App auth + HTTP client. Returns nil when the section is empty (the +// client is reset to nil so endpoints return ErrGitHubAppNotConfigured). +// +// Called by the instance-admin handler whenever the admin saves the +// github_app section, so the new credentials take effect without an API +// restart. +func (s *IntegrationService) ReloadGitHubClient(ctx context.Context) error { + app, err := LoadGitHubAppFromSettings(ctx, s.set) + if err != nil { + return err + } + if app == nil { + s.githubClient = nil + return nil + } + s.githubClient = github.NewClient(app, nil) + return nil +} + +// ListAvailable returns all registered providers (for the "available +// integrations" gallery). +func (s *IntegrationService) ListAvailable(ctx context.Context) ([]model.Integration, error) { + return s.is.List(ctx) +} + +// ListInstalled returns the workspace's installed integrations. +func (s *IntegrationService) ListInstalled(ctx context.Context, workspaceSlug string, userID uuid.UUID) ([]model.WorkspaceIntegration, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, ErrWorkspaceForbidden + } + return s.wis.ListByWorkspaceID(ctx, w.ID) +} + +// GetByProvider returns the workspace's installed integration for the given +// provider, or ErrIntegrationNotFound. Verifies workspace membership. +func (s *IntegrationService) GetByProvider(ctx context.Context, workspaceSlug, provider string, userID uuid.UUID) (*model.WorkspaceIntegration, error) { + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, ErrWorkspaceForbidden + } + wi, err := s.wis.GetByWorkspaceAndProvider(ctx, w.ID, provider) + if err != nil { + return nil, ErrIntegrationNotFound + } + return wi, nil +} + +// InstallGitHub creates (or updates) a workspace_integrations row for a fresh +// GitHub App installation. Called from the App callback after the user +// completes the install flow on github.com. +// +// installationID is the value GitHub redirected back with as ?installation_id= +// — we trust it because the user's session ties it to a workspace via OAuth state. +func (s *IntegrationService) InstallGitHub(ctx context.Context, workspaceSlug string, userID uuid.UUID, installationID int64) (*model.WorkspaceIntegration, error) { + if installationID <= 0 { + return nil, errors.New("installation id is required") + } + w, err := s.ws.GetBySlug(ctx, workspaceSlug) + if err != nil { + return nil, ErrWorkspaceNotFound + } + ok, _ := s.ws.IsMember(ctx, w.ID, userID) + if !ok { + return nil, ErrWorkspaceForbidden + } + gh, err := s.is.GetByProvider(ctx, "github") + if err != nil { + return nil, ErrIntegrationNotFound + } + + // Verify the installation by fetching its metadata (owner, repos...). + if s.githubClient == nil { + return nil, ErrGitHubAppNotConfigured + } + account, err := s.fetchInstallationAccount(ctx, installationID) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInstallationFetch, err) + } + + // One installation may only map to one workspace. If a row already exists for + // this installation in another workspace, re-point it (the latest installer wins). + if existing, err := s.wis.GetByInstallationID(ctx, installationID); err == nil && existing != nil { + if existing.WorkspaceID == w.ID { + // Same workspace — refresh metadata and return. + existing.AccountLogin = account.Login + existing.AccountType = account.Type + existing.AccountAvatarURL = account.AvatarURL + existing.SuspendedAt = nil + existing.UpdatedByID = &userID + if existing.Metadata == nil { + existing.Metadata = model.JSONMap{} + } + existing.Metadata["account_id"] = account.ID + if err := s.wis.Update(ctx, existing); err != nil { + return nil, err + } + existing.Provider = "github" + return existing, nil + } + // Different workspace — soft-delete the old row so the unique partial + // index frees up, then create the new row. + if err := s.wis.Delete(ctx, existing.ID); err != nil { + return nil, err + } + } + + // New installation in this workspace — error if the workspace already has + // a different GitHub installation (one per workspace). + if existing, err := s.wis.GetByWorkspaceAndProvider(ctx, w.ID, "github"); err == nil && existing != nil { + // Re-point the existing row to the new installation_id. + existing.InstallationID = &installationID + existing.AccountLogin = account.Login + existing.AccountType = account.Type + existing.AccountAvatarURL = account.AvatarURL + existing.SuspendedAt = nil + existing.UpdatedByID = &userID + if existing.Metadata == nil { + existing.Metadata = model.JSONMap{} + } + existing.Metadata["account_id"] = account.ID + if err := s.wis.Update(ctx, existing); err != nil { + return nil, err + } + existing.Provider = "github" + return existing, nil + } + + wi := &model.WorkspaceIntegration{ + WorkspaceID: w.ID, + ActorID: userID, + IntegrationID: gh.ID, + InstallationID: &installationID, + AccountLogin: account.Login, + AccountType: account.Type, + AccountAvatarURL: account.AvatarURL, + Metadata: model.JSONMap{"account_id": account.ID}, + Config: model.JSONMap{}, + CreatedByID: &userID, + } + if err := s.wis.Create(ctx, wi); err != nil { + return nil, err + } + wi.Provider = "github" + return wi, nil +} + +// Uninstall removes a workspace_integrations row by provider. The owning +// GitHub App installation is NOT auto-removed from github.com — the admin must +// remove it there too if they want the App fully revoked. +func (s *IntegrationService) Uninstall(ctx context.Context, workspaceSlug, provider string, userID uuid.UUID) error { + wi, err := s.GetByProvider(ctx, workspaceSlug, provider, userID) + if err != nil { + return err + } + if provider == "github" && s.githubClient != nil && wi.InstallationID != nil { + s.githubClient.InvalidateInstallation(*wi.InstallationID) + } + return s.wis.Delete(ctx, wi.ID) +} + +// fetchInstallationAccount calls GET /app/installations/:id to get the account +// the App is installed for (org or user). Returns the embedded AccountLite so +// the caller can hydrate workspace_integrations.account_* fields. +func (s *IntegrationService) fetchInstallationAccount(ctx context.Context, installationID int64) (github.AccountLite, error) { + inst, err := s.githubClient.GetInstallation(ctx, installationID) + if err != nil { + return github.AccountLite{}, err + } + return inst.Account, nil +} + +// LoadGitHubAppFromSettings builds a github.AppAuth + github.Client from the +// `github_app` instance_settings section. Returns nil, nil when the section is +// not configured (so the integration UI can show a helpful "configure first" +// message rather than crashing). +func LoadGitHubAppFromSettings(ctx context.Context, set *store.InstanceSettingStore) (*github.AppAuth, error) { + if set == nil { + return nil, nil + } + row, err := set.Get(ctx, "github_app") + if err != nil || row == nil { + return nil, nil + } + v := row.Value + appIDStr, _ := v["app_id"].(string) + appIDStr = strings.TrimSpace(appIDStr) + if appIDStr == "" { + return nil, nil + } + appID, err := strconv.ParseInt(appIDStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("github_app.app_id must be a number: %w", err) + } + pkEnc, _ := v["private_key"].(string) + if pkEnc == "" { + return nil, nil + } + pk := crypto.DecryptOrPlain(pkEnc) + if pk == "" { + return nil, errors.New("github_app.private_key is set but could not be decrypted (check INSTANCE_ENCRYPTION_KEY)") + } + return github.NewAppAuth(appID, pk) +} + +// LoadGitHubWebhookSecretFromSettings returns the decrypted webhook secret, or +// "" when not configured. +func LoadGitHubWebhookSecretFromSettings(ctx context.Context, set *store.InstanceSettingStore) string { + if set == nil { + return "" + } + row, err := set.Get(ctx, "github_app") + if err != nil || row == nil { + return "" + } + v, _ := row.Value["webhook_secret"].(string) + if v == "" { + return "" + } + return crypto.DecryptOrPlain(v) +} + +// LoadGitHubAppNameFromSettings returns the App slug used to build the +// installation URL (https://github.com/apps//installations/new). +func LoadGitHubAppNameFromSettings(ctx context.Context, set *store.InstanceSettingStore) string { + if set == nil { + return "" + } + row, err := set.Get(ctx, "github_app") + if err != nil || row == nil { + return "" + } + s, _ := row.Value["app_name"].(string) + return strings.TrimSpace(s) +} diff --git a/api/internal/service/issue.go b/api/internal/service/issue.go index cbc6917e..c70d362e 100644 --- a/api/internal/service/issue.go +++ b/api/internal/service/issue.go @@ -17,15 +17,50 @@ var ( // IssueService handles issue business logic. type IssueService struct { - is *store.IssueStore - ps *store.ProjectStore - ws *store.WorkspaceStore + is *store.IssueStore + ps *store.ProjectStore + ws *store.WorkspaceStore + activity *store.IssueActivityStore // optional — may be nil } func NewIssueService(is *store.IssueStore, ps *store.ProjectStore, ws *store.WorkspaceStore) *IssueService { return &IssueService{is: is, ps: ps, ws: ws} } +// SetActivityStore injects the activity store so Update can record field changes. +// Optional — left as a setter so existing callers don't need to change. +func (s *IssueService) SetActivityStore(a *store.IssueActivityStore) { s.activity = a } + +// recordActivity inserts one issue_activities row. Errors are logged-and-ignored +// — we never fail an issue update because the activity write fails. +func (s *IssueService) recordActivity(ctx context.Context, issue *model.Issue, userID uuid.UUID, field string, oldVal, newVal string) { + if s.activity == nil { + return + } + verb := "updated" + f := field + row := &model.IssueActivity{ + IssueID: &issue.ID, + ProjectID: issue.ProjectID, + WorkspaceID: issue.WorkspaceID, + Verb: verb, + Field: &f, + OldValue: nullableStr(oldVal), + NewValue: nullableStr(newVal), + ActorID: &userID, + CreatedByID: &userID, + } + _ = s.activity.Create(ctx, row) +} + +func nullableStr(s string) *string { + if s == "" { + return nil + } + out := s + return &out +} + func (s *IssueService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error { wrk, err := s.ws.GetBySlug(ctx, workspaceSlug) if err != nil { @@ -183,6 +218,20 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project if len(labelIDs) > 0 { _ = s.ReplaceLabels(ctx, workspaceSlug, projectID, issue.ID, userID, labelIDs) } + // Record the synthetic "created" activity row so the activity feed has a + // defined start. We don't snapshot fields here — the create call captures + // them; future updates emit field-change activity rows. + if s.activity != nil { + row := &model.IssueActivity{ + IssueID: &issue.ID, + ProjectID: issue.ProjectID, + WorkspaceID: issue.WorkspaceID, + Verb: "created", + ActorID: &userID, + CreatedByID: &userID, + } + _ = s.activity.Create(ctx, row) + } return issue, nil } @@ -191,6 +240,15 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project if err != nil { return nil, err } + + // Snapshot values before mutation so we can diff them for the activity log. + prevName := issue.Name + prevPriority := issue.Priority + prevState := uuidString(issue.StateID) + prevStart := dateString(issue.StartDate) + prevTarget := dateString(issue.TargetDate) + prevParent := uuidString(issue.ParentID) + if name != nil { issue.Name = *name } @@ -219,15 +277,86 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project if err := s.is.Update(ctx, issue); err != nil { return nil, err } + + // Activity log — record what changed. Description is intentionally not logged + // (it's noisy and the change history is rebuildable from issue versions). + if name != nil && prevName != issue.Name { + s.recordActivity(ctx, issue, userID, "name", prevName, issue.Name) + } + if priority != nil && prevPriority != issue.Priority { + s.recordActivity(ctx, issue, userID, "priority", prevPriority, issue.Priority) + } + if stateID != nil && prevState != uuidString(issue.StateID) { + s.recordActivity(ctx, issue, userID, "state", prevState, uuidString(issue.StateID)) + } + if startDate != nil && prevStart != dateString(issue.StartDate) { + s.recordActivity(ctx, issue, userID, "start_date", prevStart, dateString(issue.StartDate)) + } + if targetDate != nil && prevTarget != dateString(issue.TargetDate) { + s.recordActivity(ctx, issue, userID, "target_date", prevTarget, dateString(issue.TargetDate)) + } + if parentID != nil && prevParent != uuidString(issue.ParentID) { + s.recordActivity(ctx, issue, userID, "parent", prevParent, uuidString(issue.ParentID)) + } + if assigneeIDs != nil { + prevAssignees, _ := s.is.ListAssigneesForIssue(ctx, issue.ID) _ = s.ReplaceAssignees(ctx, workspaceSlug, projectID, issue.ID, userID, *assigneeIDs) + // Diff added vs removed for nicer activity entries. + prevSet := uuidSet(prevAssignees) + newSet := uuidSet(*assigneeIDs) + for id := range newSet { + if !prevSet[id] { + s.recordActivity(ctx, issue, userID, "assignees_added", "", id.String()) + } + } + for id := range prevSet { + if !newSet[id] { + s.recordActivity(ctx, issue, userID, "assignees_removed", id.String(), "") + } + } } if labelIDs != nil { + prevLabels, _ := s.is.ListLabelsForIssue(ctx, issue.ID) _ = s.ReplaceLabels(ctx, workspaceSlug, projectID, issue.ID, userID, *labelIDs) + prevSet := uuidSet(prevLabels) + newSet := uuidSet(*labelIDs) + for id := range newSet { + if !prevSet[id] { + s.recordActivity(ctx, issue, userID, "labels_added", "", id.String()) + } + } + for id := range prevSet { + if !newSet[id] { + s.recordActivity(ctx, issue, userID, "labels_removed", id.String(), "") + } + } } return issue, nil } +func uuidString(id *uuid.UUID) string { + if id == nil || *id == uuid.Nil { + return "" + } + return id.String() +} + +func dateString(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("2006-01-02") +} + +func uuidSet(ids []uuid.UUID) map[uuid.UUID]bool { + out := make(map[uuid.UUID]bool, len(ids)) + for _, id := range ids { + out[id] = true + } + return out +} + func (s *IssueService) Delete(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID) error { _, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID) if err != nil { @@ -309,3 +438,15 @@ func (s *IssueService) ReplaceLabels(ctx context.Context, workspaceSlug string, } return nil } + +// ListActivities returns the chronological activity log for an issue. +// Returns an empty slice when the activity store isn't wired (defensive). +func (s *IssueService) ListActivities(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID) ([]model.IssueActivity, error) { + if _, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID); err != nil { + return nil, err + } + if s.activity == nil { + return []model.IssueActivity{}, nil + } + return s.activity.ListByIssueID(ctx, issueID) +} diff --git a/api/internal/store/comment_reaction.go b/api/internal/store/comment_reaction.go new file mode 100644 index 00000000..1f7268d1 --- /dev/null +++ b/api/internal/store/comment_reaction.go @@ -0,0 +1,52 @@ +package store + +import ( + "context" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// CommentReactionStore handles comment_reactions persistence. +type CommentReactionStore struct{ db *gorm.DB } + +func NewCommentReactionStore(db *gorm.DB) *CommentReactionStore { + return &CommentReactionStore{db: db} +} + +// ListByCommentID returns all reactions for a single comment. +func (s *CommentReactionStore) ListByCommentID(ctx context.Context, commentID uuid.UUID) ([]model.CommentReaction, error) { + var list []model.CommentReaction + err := s.db.WithContext(ctx). + Where("comment_id = ?", commentID). + Order("created_at ASC"). + Find(&list).Error + return list, err +} + +// ListByCommentIDs returns reactions for many comments at once (used to hydrate +// the comments thread in a single query). +func (s *CommentReactionStore) ListByCommentIDs(ctx context.Context, commentIDs []uuid.UUID) ([]model.CommentReaction, error) { + if len(commentIDs) == 0 { + return nil, nil + } + var list []model.CommentReaction + err := s.db.WithContext(ctx). + Where("comment_id IN ?", commentIDs). + Order("created_at ASC"). + Find(&list).Error + return list, err +} + +// Add inserts a reaction (no-op on conflict due to the unique index). +func (s *CommentReactionStore) Add(ctx context.Context, r *model.CommentReaction) error { + return s.db.WithContext(ctx).Create(r).Error +} + +// Remove deletes one user's reaction. +func (s *CommentReactionStore) Remove(ctx context.Context, commentID, actorID uuid.UUID, reaction string) error { + return s.db.WithContext(ctx). + Where("comment_id = ? AND actor_id = ? AND reaction = ?", commentID, actorID, reaction). + Delete(&model.CommentReaction{}).Error +} diff --git a/api/internal/store/integration.go b/api/internal/store/integration.go new file mode 100644 index 00000000..22af7a18 --- /dev/null +++ b/api/internal/store/integration.go @@ -0,0 +1,410 @@ +package store + +import ( + "context" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// IntegrationStore handles the integration provider registry. +type IntegrationStore struct{ db *gorm.DB } + +func NewIntegrationStore(db *gorm.DB) *IntegrationStore { return &IntegrationStore{db: db} } + +func (s *IntegrationStore) List(ctx context.Context) ([]model.Integration, error) { + var list []model.Integration + err := s.db.WithContext(ctx).Order("title ASC").Find(&list).Error + return list, err +} + +func (s *IntegrationStore) GetByProvider(ctx context.Context, provider string) (*model.Integration, error) { + var i model.Integration + err := s.db.WithContext(ctx).Where("provider = ?", provider).First(&i).Error + if err != nil { + return nil, err + } + return &i, nil +} + +// WorkspaceIntegrationStore handles workspace-scoped integration installations. +type WorkspaceIntegrationStore struct{ db *gorm.DB } + +func NewWorkspaceIntegrationStore(db *gorm.DB) *WorkspaceIntegrationStore { + return &WorkspaceIntegrationStore{db: db} +} + +func (s *WorkspaceIntegrationStore) Create(ctx context.Context, w *model.WorkspaceIntegration) error { + return s.db.WithContext(ctx).Create(w).Error +} + +func (s *WorkspaceIntegrationStore) Update(ctx context.Context, w *model.WorkspaceIntegration) error { + return s.db.WithContext(ctx).Save(w).Error +} + +func (s *WorkspaceIntegrationStore) GetByID(ctx context.Context, id uuid.UUID) (*model.WorkspaceIntegration, error) { + var w model.WorkspaceIntegration + err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&w).Error + if err != nil { + return nil, err + } + return &w, nil +} + +func (s *WorkspaceIntegrationStore) GetByInstallationID(ctx context.Context, installationID int64) (*model.WorkspaceIntegration, error) { + var w model.WorkspaceIntegration + err := s.db.WithContext(ctx).Where("installation_id = ? AND deleted_at IS NULL", installationID).First(&w).Error + if err != nil { + return nil, err + } + return &w, nil +} + +func (s *WorkspaceIntegrationStore) GetByWorkspaceAndProvider(ctx context.Context, workspaceID uuid.UUID, provider string) (*model.WorkspaceIntegration, error) { + var w model.WorkspaceIntegration + err := s.db.WithContext(ctx). + Table("workspace_integrations AS wi"). + Select("wi.*, integrations.provider AS provider"). + Joins("INNER JOIN integrations ON integrations.id = wi.integration_id"). + Where("wi.workspace_id = ? AND integrations.provider = ? AND wi.deleted_at IS NULL", workspaceID, provider). + First(&w).Error + if err != nil { + return nil, err + } + return &w, nil +} + +func (s *WorkspaceIntegrationStore) ListByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceIntegration, error) { + var list []model.WorkspaceIntegration + err := s.db.WithContext(ctx). + Table("workspace_integrations AS wi"). + Select("wi.*, integrations.provider AS provider"). + Joins("INNER JOIN integrations ON integrations.id = wi.integration_id"). + Where("wi.workspace_id = ? AND wi.deleted_at IS NULL", workspaceID). + Order("wi.created_at DESC"). + Find(&list).Error + return list, err +} + +func (s *WorkspaceIntegrationStore) Delete(ctx context.Context, id uuid.UUID) error { + return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.WorkspaceIntegration{}).Error +} + +// MarkSuspended toggles suspension state (GitHub fires installation.suspend / unsuspend). +func (s *WorkspaceIntegrationStore) MarkSuspended(ctx context.Context, id uuid.UUID, suspended bool) error { + updates := map[string]interface{}{} + if suspended { + now := time.Now().UTC() + updates["suspended_at"] = &now + } else { + updates["suspended_at"] = nil + } + return s.db.WithContext(ctx).Model(&model.WorkspaceIntegration{}). + Where("id = ?", id). + Updates(updates).Error +} + +// GithubRepositoryStore handles github_repositories rows. +type GithubRepositoryStore struct{ db *gorm.DB } + +func NewGithubRepositoryStore(db *gorm.DB) *GithubRepositoryStore { + return &GithubRepositoryStore{db: db} +} + +func (s *GithubRepositoryStore) Create(ctx context.Context, r *model.GithubRepository) error { + return s.db.WithContext(ctx).Create(r).Error +} + +func (s *GithubRepositoryStore) GetByID(ctx context.Context, id uuid.UUID) (*model.GithubRepository, error) { + var r model.GithubRepository + err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&r).Error + if err != nil { + return nil, err + } + return &r, nil +} + +func (s *GithubRepositoryStore) GetByRepositoryID(ctx context.Context, workspaceID uuid.UUID, repositoryID int64) ([]model.GithubRepository, error) { + var list []model.GithubRepository + err := s.db.WithContext(ctx). + Where("workspace_id = ? AND repository_id = ? AND deleted_at IS NULL", workspaceID, repositoryID). + Find(&list).Error + return list, err +} + +func (s *GithubRepositoryStore) Update(ctx context.Context, r *model.GithubRepository) error { + return s.db.WithContext(ctx).Save(r).Error +} + +func (s *GithubRepositoryStore) Delete(ctx context.Context, id uuid.UUID) error { + return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.GithubRepository{}).Error +} + +// GithubRepositorySyncStore handles github_repository_syncs rows. +type GithubRepositorySyncStore struct{ db *gorm.DB } + +func NewGithubRepositorySyncStore(db *gorm.DB) *GithubRepositorySyncStore { + return &GithubRepositorySyncStore{db: db} +} + +func (s *GithubRepositorySyncStore) Create(ctx context.Context, r *model.GithubRepositorySync) error { + return s.db.WithContext(ctx).Create(r).Error +} + +func (s *GithubRepositorySyncStore) GetByID(ctx context.Context, id uuid.UUID) (*model.GithubRepositorySync, error) { + var r model.GithubRepositorySync + err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&r).Error + if err != nil { + return nil, err + } + return &r, nil +} + +func (s *GithubRepositorySyncStore) GetByProjectID(ctx context.Context, projectID uuid.UUID) (*model.GithubRepositorySync, error) { + var r model.GithubRepositorySync + err := s.db.WithContext(ctx).Where("project_id = ? AND deleted_at IS NULL", projectID).First(&r).Error + if err != nil { + return nil, err + } + return &r, nil +} + +// ListByWorkspaceIntegrationID returns all repo syncs for an installed integration. +// Used to fan webhook events out across linked projects. +func (s *GithubRepositorySyncStore) ListByWorkspaceIntegrationID(ctx context.Context, integrationID uuid.UUID) ([]model.GithubRepositorySync, error) { + var list []model.GithubRepositorySync + err := s.db.WithContext(ctx). + Where("workspace_integration_id = ? AND deleted_at IS NULL", integrationID). + Find(&list).Error + return list, err +} + +// ListByGithubRepoID returns syncs across projects within a workspace pointing +// at the same GitHub repository_id. We join to github_repositories so the +// caller can find every Devlane project linked to a given repo. +func (s *GithubRepositorySyncStore) ListByGithubRepoID(ctx context.Context, workspaceID uuid.UUID, repositoryID int64) ([]model.GithubRepositorySync, error) { + var list []model.GithubRepositorySync + err := s.db.WithContext(ctx). + Table("github_repository_syncs AS s"). + Select("s.*"). + Joins("INNER JOIN github_repositories AS r ON r.id = s.repository_id AND r.deleted_at IS NULL"). + Where("s.workspace_id = ? AND r.repository_id = ? AND s.deleted_at IS NULL", workspaceID, repositoryID). + Find(&list).Error + return list, err +} + +func (s *GithubRepositorySyncStore) Update(ctx context.Context, r *model.GithubRepositorySync) error { + return s.db.WithContext(ctx).Save(r).Error +} + +func (s *GithubRepositorySyncStore) Delete(ctx context.Context, id uuid.UUID) error { + return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.GithubRepositorySync{}).Error +} + +// GithubIssueSyncStore handles github_issue_syncs rows (PR↔issue links). +type GithubIssueSyncStore struct{ db *gorm.DB } + +func NewGithubIssueSyncStore(db *gorm.DB) *GithubIssueSyncStore { + return &GithubIssueSyncStore{db: db} +} + +func (s *GithubIssueSyncStore) Create(ctx context.Context, g *model.GithubIssueSync) error { + return s.db.WithContext(ctx).Create(g).Error +} + +func (s *GithubIssueSyncStore) Update(ctx context.Context, g *model.GithubIssueSync) error { + return s.db.WithContext(ctx).Save(g).Error +} + +func (s *GithubIssueSyncStore) GetByID(ctx context.Context, id uuid.UUID) (*model.GithubIssueSync, error) { + var g model.GithubIssueSync + err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&g).Error + if err != nil { + return nil, err + } + return &g, nil +} + +// GetByPR returns the link for a (repository_sync, repo_issue_id, kind) tuple. +func (s *GithubIssueSyncStore) GetByPR(ctx context.Context, repositorySyncID uuid.UUID, repoIssueID int64, kind string) (*model.GithubIssueSync, error) { + var g model.GithubIssueSync + err := s.db.WithContext(ctx). + Where("repository_sync_id = ? AND repo_issue_id = ? AND kind = ? AND deleted_at IS NULL", repositorySyncID, repoIssueID, kind). + First(&g).Error + if err != nil { + return nil, err + } + return &g, nil +} + +// ListByPR returns every link for a (repository_sync, repo_issue_id, kind) — a +// PR may reference multiple issues. +func (s *GithubIssueSyncStore) ListByPR(ctx context.Context, repositorySyncID uuid.UUID, repoIssueID int64, kind string) ([]model.GithubIssueSync, error) { + var list []model.GithubIssueSync + err := s.db.WithContext(ctx). + Where("repository_sync_id = ? AND repo_issue_id = ? AND kind = ? AND deleted_at IS NULL", repositorySyncID, repoIssueID, kind). + Find(&list).Error + return list, err +} + +// ListByIssueID returns all GitHub PR links for a Devlane issue (for the issue +// detail sidebar). +func (s *GithubIssueSyncStore) ListByIssueID(ctx context.Context, issueID uuid.UUID) ([]model.GithubIssueSync, error) { + var list []model.GithubIssueSync + err := s.db.WithContext(ctx). + Where("issue_id = ? AND deleted_at IS NULL", issueID). + Order("updated_at DESC"). + Find(&list).Error + return list, err +} + +// UpsertByPRAndIssue creates or updates the link for a (sync, repo_issue, issue) tuple. +func (s *GithubIssueSyncStore) UpsertByPRAndIssue(ctx context.Context, g *model.GithubIssueSync) (*model.GithubIssueSync, error) { + var existing model.GithubIssueSync + err := s.db.WithContext(ctx). + Where("repository_sync_id = ? AND repo_issue_id = ? AND issue_id = ? AND kind = ? AND deleted_at IS NULL", + g.RepositorySyncID, g.RepoIssueID, g.IssueID, g.Kind). + First(&existing).Error + if err == gorm.ErrRecordNotFound { + if err := s.db.WithContext(ctx).Create(g).Error; err != nil { + return nil, err + } + return g, nil + } + if err != nil { + return nil, err + } + // Preserve identity of existing row, copy mutable fields. + existing.GithubIssueID = g.GithubIssueID + existing.IssueURL = g.IssueURL + existing.State = g.State + existing.Title = g.Title + existing.Draft = g.Draft + existing.MergedAt = g.MergedAt + existing.ClosedAt = g.ClosedAt + existing.AuthorLogin = g.AuthorLogin + existing.BaseBranch = g.BaseBranch + existing.HeadBranch = g.HeadBranch + if g.DetectionSource != "" { + existing.DetectionSource = g.DetectionSource + } + if err := s.db.WithContext(ctx).Save(&existing).Error; err != nil { + return nil, err + } + return &existing, nil +} + +func (s *GithubIssueSyncStore) Delete(ctx context.Context, id uuid.UUID) error { + return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.GithubIssueSync{}).Error +} + +// IssueSummary aggregates PR counts per issue, used by the issues list page +// to show a small badge next to each row. The map only includes issues that +// have at least one link; absent IDs in the map mean "no PRs". +type IssueSummary struct { + IssueID uuid.UUID `json:"issue_id"` + Total int `json:"total"` + Open int `json:"open"` + Merged int `json:"merged"` + Closed int `json:"closed"` + Draft int `json:"draft"` + LatestState string `json:"latest_state"` // state of the most recently updated link +} + +// SummaryForIssues runs one aggregate query per project for a slice of issue IDs. +func (s *GithubIssueSyncStore) SummaryForIssues(ctx context.Context, projectID uuid.UUID, issueIDs []uuid.UUID) (map[uuid.UUID]IssueSummary, error) { + out := make(map[uuid.UUID]IssueSummary) + if len(issueIDs) == 0 { + return out, nil + } + type row struct { + IssueID uuid.UUID + Total int + Open int + Merged int + Closed int + Draft int + } + var rows []row + err := s.db.WithContext(ctx). + Table("github_issue_syncs"). + Select(`issue_id, + COUNT(*) AS total, + SUM(CASE WHEN state = 'open' THEN 1 ELSE 0 END) AS open, + SUM(CASE WHEN state = 'merged' THEN 1 ELSE 0 END) AS merged, + SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END) AS closed, + SUM(CASE WHEN draft = TRUE THEN 1 ELSE 0 END) AS draft`). + Where("project_id = ? AND issue_id IN ? AND deleted_at IS NULL AND kind = 'pull_request'", projectID, issueIDs). + Group("issue_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + // Latest state per issue (newest updated_at wins). + type latest struct { + IssueID uuid.UUID + State string + } + var latestRows []latest + err = s.db.WithContext(ctx).Raw(` + SELECT DISTINCT ON (issue_id) issue_id, state + FROM github_issue_syncs + WHERE project_id = ? AND issue_id IN ? AND deleted_at IS NULL AND kind = 'pull_request' + ORDER BY issue_id, updated_at DESC + `, projectID, issueIDs).Scan(&latestRows).Error + if err != nil { + return nil, err + } + latestByID := make(map[uuid.UUID]string, len(latestRows)) + for _, l := range latestRows { + latestByID[l.IssueID] = l.State + } + + for _, r := range rows { + out[r.IssueID] = IssueSummary{ + IssueID: r.IssueID, + Total: r.Total, + Open: r.Open, + Merged: r.Merged, + Closed: r.Closed, + Draft: r.Draft, + LatestState: latestByID[r.IssueID], + } + } + return out, nil +} + +// GithubWebhookEventStore handles inbound webhook log rows. +type GithubWebhookEventStore struct{ db *gorm.DB } + +func NewGithubWebhookEventStore(db *gorm.DB) *GithubWebhookEventStore { + return &GithubWebhookEventStore{db: db} +} + +func (s *GithubWebhookEventStore) Create(ctx context.Context, e *model.GithubWebhookEvent) error { + return s.db.WithContext(ctx).Create(e).Error +} + +func (s *GithubWebhookEventStore) MarkProcessed(ctx context.Context, id uuid.UUID, status, errMsg string) error { + now := time.Now().UTC() + return s.db.WithContext(ctx).Model(&model.GithubWebhookEvent{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "status": status, + "error": errMsg, + "processed_at": &now, + }).Error +} + +// ExistsByDeliveryID returns true if a webhook delivery has already been recorded. +// Used for idempotency — GitHub may retry deliveries. +func (s *GithubWebhookEventStore) ExistsByDeliveryID(ctx context.Context, deliveryID string) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.GithubWebhookEvent{}). + Where("delivery_id = ?", deliveryID). + Count(&count).Error + return count > 0, err +} diff --git a/api/internal/store/issue.go b/api/internal/store/issue.go index b6760424..aea765a2 100644 --- a/api/internal/store/issue.go +++ b/api/internal/store/issue.go @@ -50,6 +50,19 @@ func (s *IssueStore) GetByID(ctx context.Context, id uuid.UUID) (*model.Issue, e return &i, nil } +// GetByProjectAndSequence resolves the per-project sequence number to an issue. +// Used by the GitHub integration to map "DEV-42" → an issue. +func (s *IssueStore) GetByProjectAndSequence(ctx context.Context, projectID uuid.UUID, sequenceID int) (*model.Issue, error) { + var i model.Issue + err := s.db.WithContext(ctx). + Where("project_id = ? AND sequence_id = ? AND deleted_at IS NULL", projectID, sequenceID). + First(&i).Error + if err != nil { + return nil, err + } + return &i, nil +} + // ListByIDs returns issues by IDs (order not preserved). func (s *IssueStore) ListByIDs(ctx context.Context, ids []uuid.UUID) ([]model.Issue, error) { if len(ids) == 0 { diff --git a/api/internal/store/issue_activity.go b/api/internal/store/issue_activity.go new file mode 100644 index 00000000..ab6d1bc8 --- /dev/null +++ b/api/internal/store/issue_activity.go @@ -0,0 +1,28 @@ +package store + +import ( + "context" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// IssueActivityStore handles issue_activities persistence. +type IssueActivityStore struct{ db *gorm.DB } + +func NewIssueActivityStore(db *gorm.DB) *IssueActivityStore { return &IssueActivityStore{db: db} } + +func (s *IssueActivityStore) Create(ctx context.Context, a *model.IssueActivity) error { + return s.db.WithContext(ctx).Create(a).Error +} + +// ListByIssueID returns activities for an issue ordered chronologically. +func (s *IssueActivityStore) ListByIssueID(ctx context.Context, issueID uuid.UUID) ([]model.IssueActivity, error) { + var list []model.IssueActivity + err := s.db.WithContext(ctx). + Where("issue_id = ? AND deleted_at IS NULL", issueID). + Order("created_at ASC"). + Find(&list).Error + return list, err +} diff --git a/api/internal/store/project.go b/api/internal/store/project.go index e5f996fb..ce7d274a 100644 --- a/api/internal/store/project.go +++ b/api/internal/store/project.go @@ -40,6 +40,19 @@ func (s *ProjectStore) Delete(ctx context.Context, id uuid.UUID) error { return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Project{}).Error } +// GetByWorkspaceAndIdentifier finds a project by its identifier (case-insensitive) +// within a workspace. Used to resolve PR refs like "DEV-42" → project DEV. +func (s *ProjectStore) GetByWorkspaceAndIdentifier(ctx context.Context, workspaceID uuid.UUID, identifier string) (*model.Project, error) { + var p model.Project + err := s.db.WithContext(ctx). + Where("workspace_id = ? AND UPPER(identifier) = UPPER(?) AND deleted_at IS NULL", workspaceID, identifier). + First(&p).Error + if err != nil { + return nil, err + } + return &p, nil +} + // IsInWorkspace checks that the project belongs to the workspace. func (s *ProjectStore) IsInWorkspace(ctx context.Context, projectID, workspaceID uuid.UUID) (bool, error) { var count int64 diff --git a/api/migrations/000003_integrations_schema.down.sql b/api/migrations/000003_integrations_schema.down.sql new file mode 100644 index 00000000..f7b98f1e --- /dev/null +++ b/api/migrations/000003_integrations_schema.down.sql @@ -0,0 +1,34 @@ +DELETE FROM instance_settings WHERE key = 'github_app'; +DELETE FROM integrations WHERE provider = 'github'; + +DROP INDEX IF EXISTS idx_github_webhook_events_event; +DROP INDEX IF EXISTS idx_github_webhook_events_created; +DROP INDEX IF EXISTS idx_github_webhook_events_installation; +DROP TABLE IF EXISTS github_webhook_events; + +DROP INDEX IF EXISTS idx_github_issue_syncs_repository_sync; +DROP INDEX IF EXISTS idx_github_issue_syncs_repo_issue_id; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS detection_source; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS head_branch; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS base_branch; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS author_login; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS closed_at; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS merged_at; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS draft; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS title; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS state; +ALTER TABLE github_issue_syncs DROP COLUMN IF EXISTS kind; + +ALTER TABLE github_repository_syncs DROP COLUMN IF EXISTS done_state_id; +ALTER TABLE github_repository_syncs DROP COLUMN IF EXISTS in_progress_state_id; +ALTER TABLE github_repository_syncs DROP COLUMN IF EXISTS auto_close_on_merge; +ALTER TABLE github_repository_syncs DROP COLUMN IF EXISTS auto_link; + +DROP INDEX IF EXISTS idx_workspace_integrations_installation; +ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS suspended_at; +ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS account_avatar_url; +ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS account_type; +ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS account_login; +ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS installation_id; +-- NOTE: cannot safely re-apply NOT NULL on api_token_id if rows exist with NULL. +-- ALTER TABLE workspace_integrations ALTER COLUMN api_token_id SET NOT NULL; diff --git a/api/migrations/000003_integrations_schema.up.sql b/api/migrations/000003_integrations_schema.up.sql new file mode 100644 index 00000000..85f0eb53 --- /dev/null +++ b/api/migrations/000003_integrations_schema.up.sql @@ -0,0 +1,90 @@ +-- GitHub App + integrations enhancements. +-- +-- The integration tables already exist (see 000001), but they were modeled on +-- Plane's user-OAuth-token approach. For Devlane we use a GitHub App, which +-- has installation IDs (no Plane API token to associate). This migration: +-- 1. Relaxes the NOT NULL on workspace_integrations.api_token_id. +-- 2. Adds GitHub App columns (installation_id, account_login, ...). +-- 3. Extends github_issue_syncs to support pull-request sync (state, draft, +-- branches, title cache, detection source, etc). +-- 4. Adds per-repo sync settings (auto_link, auto_close_on_merge, state map). +-- 5. Adds github_webhook_events for inbound webhook observability. +-- 6. Seeds the integrations row for "github" and the github_app instance +-- settings section. + +-- 1. Allow workspace_integrations without an API token (GitHub App auth). +ALTER TABLE workspace_integrations ALTER COLUMN api_token_id DROP NOT NULL; + +-- 2. GitHub App-specific columns on workspace_integrations. +ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS installation_id BIGINT; +ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS account_login VARCHAR(255) DEFAULT ''; +ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS account_type VARCHAR(50) DEFAULT ''; +ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS account_avatar_url TEXT DEFAULT ''; +ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS suspended_at TIMESTAMPTZ; + +-- One installation can only map to one workspace_integration row at a time. +CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_integrations_installation + ON workspace_integrations (installation_id) + WHERE installation_id IS NOT NULL AND deleted_at IS NULL; + +-- 3. Per-repo sync settings. +ALTER TABLE github_repository_syncs ADD COLUMN IF NOT EXISTS auto_link BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE github_repository_syncs ADD COLUMN IF NOT EXISTS auto_close_on_merge BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE github_repository_syncs ADD COLUMN IF NOT EXISTS in_progress_state_id UUID REFERENCES states (id) ON DELETE SET NULL; +ALTER TABLE github_repository_syncs ADD COLUMN IF NOT EXISTS done_state_id UUID REFERENCES states (id) ON DELETE SET NULL; + +-- 4. Extend github_issue_syncs to cover pull-request sync. +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS kind VARCHAR(30) NOT NULL DEFAULT 'pull_request'; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS state VARCHAR(30) NOT NULL DEFAULT 'open'; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS title VARCHAR(1024) DEFAULT ''; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS draft BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS merged_at TIMESTAMPTZ; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS author_login VARCHAR(255) DEFAULT ''; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS base_branch VARCHAR(255) DEFAULT ''; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS head_branch VARCHAR(255) DEFAULT ''; +ALTER TABLE github_issue_syncs ADD COLUMN IF NOT EXISTS detection_source VARCHAR(30) DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_github_issue_syncs_repo_issue_id + ON github_issue_syncs (repo_issue_id); +CREATE INDEX IF NOT EXISTS idx_github_issue_syncs_repository_sync + ON github_issue_syncs (repository_sync_id); + +-- 5. Inbound webhook log (idempotent on delivery_id). +CREATE TABLE IF NOT EXISTS github_webhook_events ( + id UUID PRIMARY KEY, + delivery_id VARCHAR(255) NOT NULL UNIQUE, + event VARCHAR(64) NOT NULL, + action VARCHAR(64) DEFAULT '', + installation_id BIGINT, + workspace_integration_id UUID REFERENCES workspace_integrations (id) ON DELETE SET NULL, + repository_full_name VARCHAR(500) DEFAULT '', + payload JSONB NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'received', + error TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_github_webhook_events_installation ON github_webhook_events (installation_id); +CREATE INDEX IF NOT EXISTS idx_github_webhook_events_created ON github_webhook_events (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_github_webhook_events_event ON github_webhook_events (event, action); + +-- 6. Seed integration provider row + instance settings section. +INSERT INTO integrations (id, title, provider, network, description, author, verified, avatar_url, created_at, updated_at) +VALUES ( + gen_random_uuid(), + 'GitHub', + 'github', + 1, + '{"text":"Two-way sync between GitHub pull requests and Devlane issues."}'::jsonb, + 'Devlane', + TRUE, + 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png', + NOW(), + NOW() +) +ON CONFLICT (provider) DO NOTHING; + +INSERT INTO instance_settings (key, value) VALUES + ('github_app', '{"app_id":"","app_name":"","client_id":"","client_secret_set":false,"private_key_set":false,"webhook_secret_set":false}'::jsonb) +ON CONFLICT (key) DO NOTHING; diff --git a/package-lock.json b/package-lock.json index d878fd63..4037fa05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devlane", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devlane", - "version": "1.0.0", + "version": "1.1.0", "license": "ISC", "devDependencies": { "@commitlint/cli": "^19.8.1", diff --git a/package.json b/package.json index 285a7a1b..1f15bb2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devlane", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "![Devlane](./ui/public/devlane-1-dark.png)", "main": "index.js", diff --git a/ui/package-lock.json b/ui/package-lock.json index 2cb15d49..321380d7 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,18 +1,19 @@ { "name": "Devlane UI", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Devlane UI", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@headlessui/react": "^2.2.9", "@tailwindcss/vite": "^4.1.18", "@tiptap/core": "3.22.3", "@tiptap/extension-link": "3.22.3", "@tiptap/extension-list": "3.22.3", + "@tiptap/extension-mention": "^3.22.3", "@tiptap/extension-placeholder": "3.22.3", "@tiptap/extension-task-item": "3.22.3", "@tiptap/extension-task-list": "3.22.3", @@ -20,6 +21,7 @@ "@tiptap/pm": "3.22.3", "@tiptap/react": "3.22.3", "@tiptap/starter-kit": "3.22.3", + "@tiptap/suggestion": "^3.22.3", "axios": "^1.13.5", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -2347,6 +2349,21 @@ "@tiptap/extension-list": "^3.22.3" } }, + "node_modules/@tiptap/extension-mention": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.3.tgz", + "integrity": "sha512-wJmpjU6WqZgbMJUwGQKhwnzCdN/DtsFGRsExCvncuQxFKgsMzhW+NWwmzgrGJDyS8BMKzqwyKlSc1dcMOYzgJQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@tiptap/suggestion": "^3.22.3" + } + }, "node_modules/@tiptap/extension-ordered-list": { "version": "3.22.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.3.tgz", @@ -2558,6 +2575,20 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.3.tgz", + "integrity": "sha512-m2c+5gDj2vW7UI1J4JHCKehQUVE12qBhgF+DC+WEWUU8ZrFNf5OEYWQHDNsopa5RRpilfKfhPNbMtXgvGOsk6g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", diff --git a/ui/package.json b/ui/package.json index 202cea2e..6158f637 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "Devlane UI", "private": true, - "version": "0.5.1", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", @@ -19,6 +19,7 @@ "@tiptap/core": "3.22.3", "@tiptap/extension-link": "3.22.3", "@tiptap/extension-list": "3.22.3", + "@tiptap/extension-mention": "^3.22.3", "@tiptap/extension-placeholder": "3.22.3", "@tiptap/extension-task-item": "3.22.3", "@tiptap/extension-task-list": "3.22.3", @@ -26,6 +27,7 @@ "@tiptap/pm": "3.22.3", "@tiptap/react": "3.22.3", "@tiptap/starter-kit": "3.22.3", + "@tiptap/suggestion": "^3.22.3", "axios": "^1.13.5", "clsx": "^2.1.1", "lucide-react": "^0.563.0", diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index f5626aee..2f823cff 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -416,6 +416,146 @@ export interface InstanceImageSection { unsplash_access_key?: string; } +/** GitHub App config (instance admin). Secrets are never echoed back. */ +export interface InstanceGitHubAppSection { + app_id?: string; + app_name?: string; + client_id?: string; + client_secret?: string; + client_secret_set?: boolean; + private_key?: string; + private_key_set?: boolean; + webhook_secret?: string; + webhook_secret_set?: boolean; +} + +/** Available integration provider, returned by GET /api/integrations/. */ +export interface IntegrationApiResponse { + id: string; + title: string; + provider: string; + network: number; + description?: { text?: string } | Record; + author?: string; + avatar_url?: string; + verified: boolean; + metadata?: Record; +} + +/** Workspace-scoped installation, returned by GET /api/workspaces/:slug/integrations/. */ +export interface WorkspaceIntegrationApiResponse { + id: string; + workspace_id: string; + actor_id: string; + integration_id: string; + /** Provider slug from the joined integrations row (e.g. "github"). */ + provider: string; + installation_id?: number; + account_login?: string; + account_type?: string; + account_avatar_url?: string; + suspended_at?: string | null; + config?: Record; + metadata?: Record; + created_at: string; + updated_at: string; +} + +/** GitHub repository (subset returned by /api/workspaces/:slug/integrations/github/repositories/). */ +export interface GitHubRepositoryApiResponse { + id: number; + node_id: string; + name: string; + full_name: string; + private: boolean; + html_url: string; + description?: string; + default_branch?: string; + owner: { + login: string; + id: number; + type: string; + avatar_url: string; + }; +} + +export interface GitHubRepoListResponse { + total_count: number; + page: number; + per_page: number; + repositories: GitHubRepositoryApiResponse[]; +} + +/** One PR ↔ issue link row (github_issue_syncs). */ +export interface GitHubIssueLinkResponse { + id: string; + repo_issue_id: number; + github_issue_id: number; + issue_url: string; + issue_id: string; + repository_sync_id: string; + project_id: string; + workspace_id: string; + kind: 'pull_request' | 'issue'; + state: 'open' | 'merged' | 'closed' | string; + title?: string; + draft: boolean; + merged_at?: string | null; + closed_at?: string | null; + author_login?: string; + base_branch?: string; + head_branch?: string; + detection_source?: 'title' | 'body' | 'branch' | 'manual' | 'unknown' | string; + created_at: string; + updated_at: string; +} + +/** Aggregate PR counts for one issue, returned by the bulk summary endpoint. */ +export interface GitHubIssueSummaryEntry { + issue_id: string; + total: number; + open: number; + merged: number; + closed: number; + draft: number; + /** state of the most recently updated link */ + latest_state: 'open' | 'merged' | 'closed' | string; +} + +/** Response shape of GET .../integrations/github/issue-summary/. */ +export interface GitHubIssueSummaryResponse { + /** Map keyed by issue_id (UUID string). Issues with zero PRs are absent. */ + summary: Record; +} + +/** github_repository_syncs row + the joined github_repositories row. */ +export interface GitHubRepositorySyncResponse { + sync: { + id: string; + repository_id: string; + project_id: string; + workspace_id: string; + workspace_integration_id: string; + auto_link: boolean; + auto_close_on_merge: boolean; + in_progress_state_id?: string | null; + done_state_id?: string | null; + created_at: string; + updated_at: string; + }; + repository: { + id: string; + name: string; + owner: string; + url?: string; + repository_id: number; + project_id: string; + workspace_id: string; + created_at: string; + updated_at: string; + } | null; +} + /** Cycle as returned by the API */ export interface CycleApiResponse { id: string; @@ -530,6 +670,37 @@ export interface IssueCommentApiResponse { created_at: string; updated_at: string; created_by_id?: string | null; + /** "INTERNAL" (default) or "EXTERNAL". Backend already stores this column. */ + access?: 'INTERNAL' | 'EXTERNAL' | string; +} + +/** One row in the issue_activities table — a field-change or "created" event. */ +export interface IssueActivityApiResponse { + id: string; + issue_id?: string | null; + project_id: string; + workspace_id: string; + /** "created" | "updated" | "deleted". */ + verb: string; + /** When verb == "updated", which field — "name" / "state" / "priority" / etc. */ + field?: string | null; + old_value?: string | null; + new_value?: string | null; + comment?: string | null; + issue_comment_id?: string | null; + created_at: string; + updated_at: string; + actor_id?: string | null; + created_by_id?: string | null; +} + +/** One emoji reaction on a comment. */ +export interface CommentReactionApiResponse { + id: string; + comment_id: string; + reaction: string; + actor_id: string; + created_at: string; } /** Quick link (workspace user link) as returned by the API */ diff --git a/ui/src/components/integrations/IntegrationsSection.tsx b/ui/src/components/integrations/IntegrationsSection.tsx new file mode 100644 index 00000000..766d35b5 --- /dev/null +++ b/ui/src/components/integrations/IntegrationsSection.tsx @@ -0,0 +1,454 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Settings2 } from 'lucide-react'; +import { Button, Card, CardContent, Badge, Modal } from '../ui'; +import { integrationService } from '../../services/integrationService'; +import { getApiErrorMessage } from '../../api/client'; +import { RepoSyncSettingsModal } from './RepoSyncSettingsModal'; +import type { + GitHubRepositoryApiResponse, + GitHubRepositorySyncResponse, + ProjectApiResponse, + WorkspaceIntegrationApiResponse, +} from '../../api/types'; + +const IconGitHub = () => ( + + + +); + +interface IntegrationsSectionProps { + workspaceSlug: string; + projects: ProjectApiResponse[]; +} + +/** + * Workspace settings → Integrations. + * + * GitHub is the only provider for now. Layout: + * - Provider card with Connect / Manage button driven by installed status. + * - Once connected, an inline panel lists projects with their linked-repo status + * and lets the user link/unlink a repo via a modal. + */ +export function IntegrationsSection({ workspaceSlug, projects }: IntegrationsSectionProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const [installed, setInstalled] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [disconnecting, setDisconnecting] = useState(false); + + // Per-project sync rows (projectId → response or null when unlinked). + const [projectSyncs, setProjectSyncs] = useState< + Record + >({}); + + // Repo link modal state. + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [linkingProjectId, setLinkingProjectId] = useState(null); + + // Sync-settings modal state. + const [settingsOpenForProjectId, setSettingsOpenForProjectId] = useState(null); + const [repos, setRepos] = useState([]); + const [reposLoading, setReposLoading] = useState(false); + const [reposPage, setReposPage] = useState(1); + const [reposHasMore, setReposHasMore] = useState(false); + const [linking, setLinking] = useState(false); + + const github = useMemo( + () => installed.find((wi) => wi.provider === 'github') ?? null, + [installed], + ); + + const isConnected = !!github; + + // Surface OAuth callback redirect outcome (?connected=github or ?error=...). + useEffect(() => { + const connected = searchParams.get('connected'); + const errParam = searchParams.get('error'); + if (connected === 'github') { + setSuccess('GitHub connected.'); + const next = new URLSearchParams(searchParams); + next.delete('connected'); + setSearchParams(next, { replace: true }); + } else if (errParam) { + setError(errParam); + const next = new URLSearchParams(searchParams); + next.delete('error'); + setSearchParams(next, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Fetch installed integrations. + useEffect(() => { + let cancelled = false; + setLoading(true); + integrationService + .listInstalled(workspaceSlug) + .then((list) => { + if (!cancelled) setInstalled(list ?? []); + }) + .catch((e) => { + if (!cancelled) setError(getApiErrorMessage(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug]); + + // When connected, hydrate per-project sync state. + useEffect(() => { + if (!isConnected || projects.length === 0) { + setProjectSyncs({}); + return; + } + let cancelled = false; + Promise.all( + projects.map((p) => + integrationService + .githubGetProjectSync(workspaceSlug, p.id) + .then((r) => [p.id, r] as const) + .catch(() => [p.id, null] as const), + ), + ).then((entries) => { + if (cancelled) return; + const next: Record = {}; + for (const [pid, r] of entries) next[pid] = r; + setProjectSyncs(next); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, isConnected, projects]); + + const handleConnect = () => { + // Top-level navigation — GitHub will redirect us back to //settings?section=integrations. + window.location.href = integrationService.githubInstallUrl(workspaceSlug); + }; + + const handleDisconnect = async () => { + if (!confirm('Disconnect GitHub from this workspace? Linked repos will be unlinked.')) return; + setDisconnecting(true); + setError(''); + try { + await integrationService.uninstall(workspaceSlug, 'github'); + setInstalled([]); + setProjectSyncs({}); + setSuccess('GitHub disconnected.'); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setDisconnecting(false); + } + }; + + const openLinkModal = async (projectId: string) => { + setLinkingProjectId(projectId); + setLinkModalOpen(true); + setRepos([]); + setReposPage(1); + setReposHasMore(false); + setReposLoading(true); + try { + const res = await integrationService.githubListRepos(workspaceSlug, 1, 30); + setRepos(res.repositories ?? []); + setReposHasMore((res.repositories?.length ?? 0) >= 30); + setReposPage(1); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setReposLoading(false); + } + }; + + const loadMoreRepos = async () => { + if (reposLoading) return; + setReposLoading(true); + try { + const next = reposPage + 1; + const res = await integrationService.githubListRepos(workspaceSlug, next, 30); + setRepos((prev) => [...prev, ...(res.repositories ?? [])]); + setReposPage(next); + setReposHasMore((res.repositories?.length ?? 0) >= 30); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setReposLoading(false); + } + }; + + const handlePickRepo = async (repo: GitHubRepositoryApiResponse) => { + if (!linkingProjectId) return; + setLinking(true); + setError(''); + try { + const res = await integrationService.githubLinkProjectRepo(workspaceSlug, linkingProjectId, { + github_repository_id: repo.id, + owner: repo.owner.login, + name: repo.name, + url: repo.html_url, + }); + setProjectSyncs((prev) => ({ ...prev, [linkingProjectId]: res })); + setLinkModalOpen(false); + setLinkingProjectId(null); + setSuccess(`Linked ${repo.full_name}.`); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setLinking(false); + } + }; + + const handleUnlink = async (projectId: string) => { + if (!confirm('Unlink GitHub repository from this project?')) return; + setError(''); + try { + await integrationService.githubUnlinkProjectRepo(workspaceSlug, projectId); + setProjectSyncs((prev) => ({ ...prev, [projectId]: null })); + } catch (e) { + setError(getApiErrorMessage(e)); + } + }; + + return ( +
+
+

Integrations

+

+ Connect Devlane with the tools your team already uses to keep work in sync. +

+
+ + {error && ( +
+ {error} +
+ )} + {success && !error && ( +
+ {success} +
+ )} + +
+

+ Source control +

+ + +
+ + + +
+
+

GitHub

+ {isConnected ? ( + Connected + ) : ( + Available + )} + {github?.account_login && ( + @{github.account_login} + )} + {github?.suspended_at && Suspended} +
+

+ Two-way sync between GitHub pull requests and Devlane issues. Reference issues + from PRs and branches to keep status in lock-step, and see review state from the + issue sidebar. +

+ {!isConnected && ( +
    +
  • • Auto-link PRs to issues using branch names and commit messages
  • +
  • • Mirror PR status (draft, open, merged, closed) onto issue activity
  • +
  • • Move issues across states based on PR events
  • +
+ )} +
+
+
+ {loading ? ( + + ) : isConnected ? ( + + ) : ( + + )} +
+
+
+
+ + {isConnected && projects.length > 0 && ( +
+

+ Linked repositories +

+ + +
    + {projects.map((p) => { + const sync = projectSyncs[p.id]; + const repo = sync?.repository ?? null; + return ( +
  • +
    +

    + {p.name} + {p.identifier ? ( + + {p.identifier} + + ) : null} +

    + {repo ? ( +

    + + {repo.owner}/{repo.name} + +

    + ) : ( +

    No repository linked.

    + )} +
    + {repo ? ( +
    + + +
    + ) : ( + + )} +
  • + ); + })} +
+
+
+
+ )} + + { + if (!linking) { + setLinkModalOpen(false); + setLinkingProjectId(null); + } + }} + title={`Link a GitHub repository to ${ + linkingProjectId + ? (projects.find((p) => p.id === linkingProjectId)?.name ?? 'project') + : 'project' + }`} + footer={ + + } + > +
+

+ Pick a repository the GitHub App has access to. Pull requests targeting this project + will be linked to its issues. +

+
+ {repos.length === 0 && !reposLoading ? ( +

+ No repositories accessible to the installation. Add this app to a repo on GitHub + first. +

+ ) : ( +
    + {repos.map((r) => ( +
  • +
    +

    + {r.full_name} +

    + {r.description ? ( +

    {r.description}

    + ) : null} +
    + +
  • + ))} +
+ )} +
+ {reposHasMore && ( + + )} +
+
+ + {settingsOpenForProjectId && + (() => { + const proj = projects.find((p) => p.id === settingsOpenForProjectId); + if (!proj) return null; + return ( + setSettingsOpenForProjectId(null)} + workspaceSlug={workspaceSlug} + project={proj} + initialSync={projectSyncs[proj.id] ?? null} + onSaved={(next) => setProjectSyncs((prev) => ({ ...prev, [proj.id]: next }))} + /> + ); + })()} +
+ ); +} diff --git a/ui/src/components/integrations/RepoSyncSettingsModal.tsx b/ui/src/components/integrations/RepoSyncSettingsModal.tsx new file mode 100644 index 00000000..a45f5a7a --- /dev/null +++ b/ui/src/components/integrations/RepoSyncSettingsModal.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from 'react'; +import { Button, Modal } from '../ui'; +import { integrationService } from '../../services/integrationService'; +import { stateService } from '../../services/stateService'; +import { getApiErrorMessage } from '../../api/client'; +import type { + GitHubRepositorySyncResponse, + ProjectApiResponse, + StateApiResponse, +} from '../../api/types'; + +interface RepoSyncSettingsModalProps { + open: boolean; + onClose: () => void; + workspaceSlug: string; + project: ProjectApiResponse; + /** Initial sync state seeded from the parent so the modal doesn't have to refetch. */ + initialSync: GitHubRepositorySyncResponse | null; + /** Called with the updated sync row after a successful save. */ + onSaved: (next: GitHubRepositorySyncResponse) => void; +} + +/** + * Per-repo sync settings: + * - auto_link toggle + * - auto_close_on_merge toggle + * - in_progress_state_id picker (project's "started" states) + * - done_state_id picker (project's "completed" states) + * + * Without state IDs set, the engine still posts activity comments but does not + * transition the issue's state on PR open / merge. + */ +export function RepoSyncSettingsModal({ + open, + onClose, + workspaceSlug, + project, + initialSync, + onSaved, +}: RepoSyncSettingsModalProps) { + const [autoLink, setAutoLink] = useState(initialSync?.sync.auto_link ?? true); + const [autoCloseOnMerge, setAutoCloseOnMerge] = useState( + initialSync?.sync.auto_close_on_merge ?? true, + ); + const [inProgressStateID, setInProgressStateID] = useState( + initialSync?.sync.in_progress_state_id ?? '', + ); + const [doneStateID, setDoneStateID] = useState(initialSync?.sync.done_state_id ?? ''); + + const [states, setStates] = useState([]); + const [loadingStates, setLoadingStates] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + // Refresh whenever the modal opens — sync may have changed from another tab + // and we always want a fresh state list. + useEffect(() => { + if (!open) return; + setError(''); + setAutoLink(initialSync?.sync.auto_link ?? true); + setAutoCloseOnMerge(initialSync?.sync.auto_close_on_merge ?? true); + setInProgressStateID(initialSync?.sync.in_progress_state_id ?? ''); + setDoneStateID(initialSync?.sync.done_state_id ?? ''); + setLoadingStates(true); + stateService + .list(workspaceSlug, project.id) + .then((list) => setStates(list ?? [])) + .catch((e) => setError(getApiErrorMessage(e))) + .finally(() => setLoadingStates(false)); + }, [open, workspaceSlug, project.id, initialSync]); + + const startedStates = states.filter( + (s) => s.group === 'started' || s.group === 'unstarted' || s.group === 'backlog', + ); + const completedStates = states.filter((s) => s.group === 'completed' || s.group === 'cancelled'); + + const handleSave = async () => { + setError(''); + setSaving(true); + try { + // PATCH expects empty string to clear; null/undefined means "don't change". + const payload = { + auto_link: autoLink, + auto_close_on_merge: autoCloseOnMerge, + in_progress_state_id: inProgressStateID, + done_state_id: doneStateID, + }; + await integrationService.githubUpdateProjectSync(workspaceSlug, project.id, payload); + + // Refetch sync to surface server-side normalization. + const next = await integrationService.githubGetProjectSync(workspaceSlug, project.id); + if (next) onSaved(next); + onClose(); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setSaving(false); + } + }; + + return ( + { + if (!saving) onClose(); + }} + title={`Sync settings for ${project.name}`} + footer={ + <> + + + + } + > +
+ {error && ( +
+ {error} +
+ )} + + + + + +
+ + +
+ +
+ + +
+
+
+ ); +} + +function ToggleRow({ + label, + hint, + checked, + onChange, +}: { + label: string; + hint: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} diff --git a/ui/src/components/layout/InstanceAdminLayout.tsx b/ui/src/components/layout/InstanceAdminLayout.tsx index 936e7979..b9887d8b 100644 --- a/ui/src/components/layout/InstanceAdminLayout.tsx +++ b/ui/src/components/layout/InstanceAdminLayout.tsx @@ -122,6 +122,24 @@ const IconImage = () => ( ); +const IconPlug = () => ( + + + + + + +); const IconExternalLink = () => ( = { @@ -219,6 +243,7 @@ const BREADCRUMB_LABEL: Record = { authentication: 'Authentication', ai: 'Artificial Intelligence', image: 'Image', + integrations: 'Integrations', }; const AUTH_SUB_LABEL: Record = { @@ -227,6 +252,10 @@ const AUTH_SUB_LABEL: Record = { gitlab: 'GitLab', }; +const INTEGRATIONS_SUB_LABEL: Record = { + github: 'GitHub', +}; + export function InstanceAdminLayout() { const location = useLocation(); const pathname = location.pathname; @@ -234,8 +263,17 @@ export function InstanceAdminLayout() { const segments = pathname.replace(basePath, '').replace(/^\//, '').split('/').filter(Boolean); const segment = segments[0] || 'general'; const breadcrumbLabel = BREADCRUMB_LABEL[segment] ?? 'General'; - const breadcrumbTail = - segments[1] === 'create' ? 'Create' : (AUTH_SUB_LABEL[segments[1] ?? ''] ?? null); + const subKey = segments[1] ?? ''; + let breadcrumbTail: string | null = null; + if (subKey === 'create') { + breadcrumbTail = 'Create'; + } else if (segment === 'integrations') { + breadcrumbTail = INTEGRATIONS_SUB_LABEL[subKey] ?? null; + } else if (segment === 'authentication') { + breadcrumbTail = AUTH_SUB_LABEL[subKey] ?? null; + } else { + breadcrumbTail = AUTH_SUB_LABEL[subKey] ?? null; + } return (
diff --git a/ui/src/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx index 3dcc93c5..05152f40 100644 --- a/ui/src/components/layout/ModuleDetailHeader.tsx +++ b/ui/src/components/layout/ModuleDetailHeader.tsx @@ -443,12 +443,12 @@ export function ModuleDetailHeader({
-
+
diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 93178e94..a616aeed 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -650,7 +650,7 @@ function ProjectSectionDropdown({ className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm no-underline ${ isActive ? 'bg-(--brand-200) text-(--txt-primary)' - : 'text-(--txt-secondary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-primary)' + : 'text-(--txt-secondary) hover:bg-(--bg-layer-2-hover) hover:text-(--txt-primary)' }`} > @@ -926,6 +926,7 @@ function ProjectSectionHeader({ const { user: authUser } = useAuth(); const modulesFilter = useModulesFilter(); const { display: viewsDisplay, setDisplay } = useWorkspaceViewsState(); + const [searchParams, setSearchParams] = useSearchParams(); const baseUrl = `/${workspaceSlug}/projects/${projectId}`; const issuesUrl = `${baseUrl}/issues`; const [projectDropdownOpen, setProjectDropdownOpen] = useState(false); @@ -1213,48 +1214,49 @@ function ProjectSectionHeader({ if (section === 'issues') { return ( <> -
- - - - - - - -
+ {(() => { + const layouts: { key: string; label: string; icon: React.ReactNode }[] = [ + { key: 'list', label: 'List', icon: }, + { key: 'board', label: 'Board', icon: }, + { key: 'calendar', label: 'Calendar', icon: }, + { key: 'spreadsheet', label: 'Spreadsheet', icon: }, + { key: 'gantt', label: 'Timeline', icon: }, + ]; + const activeLayout = (() => { + const v = searchParams.get('layout') ?? ''; + return layouts.some((l) => l.key === v) ? v : 'list'; + })(); + const setLayout = (k: string) => { + const next = new URLSearchParams(searchParams); + if (k === 'list') next.delete('layout'); + else next.set('layout', k); + setSearchParams(next, { replace: true }); + }; + return ( +
+ {layouts.map((l) => { + const active = activeLayout === l.key; + return ( + + ); + })} +
+ ); + })()}
@@ -1441,7 +1443,7 @@ function ProjectSectionHeader({
-
+
-
-
+
diff --git a/ui/src/components/work-item/CommentEditor.tsx b/ui/src/components/work-item/CommentEditor.tsx index b472deae..9eb80399 100644 --- a/ui/src/components/work-item/CommentEditor.tsx +++ b/ui/src/components/work-item/CommentEditor.tsx @@ -1,17 +1,43 @@ -import { useEffect } from 'react'; -import { EditorContent, useEditor } from '@tiptap/react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { EditorContent, useEditor, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; import Underline from '@tiptap/extension-underline'; +import { TaskList } from '@tiptap/extension-task-list'; +import { TaskItem } from '@tiptap/extension-task-item'; +import { + Bold, + Italic, + Underline as UnderlineIcon, + Strikethrough, + List, + ListOrdered, + ListChecks, + Code2, + Quote, + Globe, + Lock, + ArrowUp, +} from 'lucide-react'; +import { createMentionExtension, type MentionMember } from './editorMention'; export interface CommentEditorProps { - onSubmit: (contentHtml: string) => void | Promise; + onSubmit: (contentHtml: string, access: 'INTERNAL' | 'EXTERNAL') => void | Promise; isSubmitting?: boolean; initialHtml?: string; placeholder?: string; autoFocus?: boolean; onCancel?: () => void; showShortcutHint?: boolean; + /** Members available for @-mention. When empty, mentions are not enabled. */ + mentionMembers?: MentionMember[]; + /** Show the INTERNAL/EXTERNAL access toggle. Defaults to false (only the + * bottom-of-page composer needs it; inline edit doesn't change access). */ + showAccessToggle?: boolean; + /** Initial access value when showAccessToggle is true. */ + initialAccess?: 'INTERNAL' | 'EXTERNAL'; + /** Label for the primary submit button. Defaults to "Send". */ + submitLabel?: string; } export function CommentEditor({ @@ -22,7 +48,22 @@ export function CommentEditor({ autoFocus = false, onCancel, showShortcutHint = false, + showAccessToggle = false, + initialAccess = 'INTERNAL', + mentionMembers, + submitLabel, }: CommentEditorProps) { + const [access, setAccess] = useState<'INTERNAL' | 'EXTERNAL'>(initialAccess); + const [isEmpty, setIsEmpty] = useState(true); + const membersRef = useRef(mentionMembers ?? []); + useEffect(() => { + membersRef.current = mentionMembers ?? []; + }, [mentionMembers]); + // The getter is only called inside TipTap's suggestion lifecycle (event-driven, + // not during render). The lint rule below can't see that, so we silence it. + const getMembers = useCallback(() => membersRef.current, []); + // eslint-disable-next-line react-hooks/refs -- ref read happens inside async editor callbacks + const mentionExt = useMemo(() => createMentionExtension(getMembers), [getMembers]); const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -31,18 +72,27 @@ export function CommentEditor({ codeBlock: {}, }), Underline, + TaskList, + TaskItem.configure({ nested: true }), Placeholder.configure({ placeholder: placeholder ?? 'Add comment', }), + mentionExt, ], content: initialHtml || '', autofocus: autoFocus ? 'end' : false, + onUpdate: ({ editor: ed }) => { + const html = ed.getHTML().trim(); + setIsEmpty(html === '

' || html === ''); + }, }); useEffect(() => { if (!editor) return; if (initialHtml !== undefined) { editor.commands.setContent(initialHtml || ''); + const html = editor.getHTML().trim(); + setIsEmpty(html === '

' || html === ''); } return () => { editor.destroy(); @@ -54,74 +104,146 @@ export function CommentEditor({ const handleSubmit = () => { if (isSubmitting) return; const html = editor.getHTML().trim(); - const isEmpty = html === '

' || html === ''; - if (isEmpty) return; - void onSubmit(html); + if (html === '

' || html === '') return; + void onSubmit(html, access); editor.commands.clearContent(); + setIsEmpty(true); }; - const buttonBase = - 'inline-flex h-8 w-8 items-center justify-center rounded border border-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-icon-secondary) disabled:opacity-40'; + const submitDisabled = isSubmitting || isEmpty; return ( -
-
- + + + )} + + editor.chain().focus().toggleBold().run()} - aria-label="Bold" + isActive={editor.isActive('bold')} + label="Bold" + shortcut="Ctrl+B" > - B - - - - - - -
+ + + +
{showShortcutHint && ( - Ctrl / Cmd + Enter to comment + + Ctrl + Enter to send + )} {onCancel && (
-
- { - if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { - event.preventDefault(); - handleSubmit(); - } - }} - /> -
); } + +function Divider() { + return ; +} + +interface ToolbarButtonProps { + editor: Editor; + onClick: () => void; + isActive: boolean; + label: string; + shortcut?: string; + children: React.ReactNode; +} + +function ToolbarButton({ onClick, isActive, label, shortcut, children }: ToolbarButtonProps) { + return ( + + ); +} diff --git a/ui/src/components/work-item/CommentReactions.tsx b/ui/src/components/work-item/CommentReactions.tsx new file mode 100644 index 00000000..fe493fff --- /dev/null +++ b/ui/src/components/work-item/CommentReactions.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef, useState } from 'react'; +import { SmilePlus } from 'lucide-react'; +import { commentService } from '../../services/commentService'; +import type { CommentReactionApiResponse } from '../../api/types'; + +const QUICK_EMOJIS = ['👍', '🎉', '❤️', '🚀', '👀', '😄']; + +interface CommentReactionsProps { + workspaceSlug: string; + projectId: string; + issueId: string; + commentId: string; + /** ID of the current user — needed to know which reactions are "mine" so we can toggle. */ + currentUserId?: string | null; +} + +/** + * Renders the reactions row under a comment + a small "add reaction" button. + * + * The picker is intentionally minimal — six common emojis. A real picker + * (with categories and search) would be a much bigger component; we can + * always swap it later. + */ +export function CommentReactions({ + workspaceSlug, + projectId, + issueId, + commentId, + currentUserId, +}: CommentReactionsProps) { + const [reactions, setReactions] = useState([]); + const [pickerOpen, setPickerOpen] = useState(false); + const wrapperRef = useRef(null); + + useEffect(() => { + let cancelled = false; + commentService + .listReactions(workspaceSlug, projectId, issueId, commentId) + .then((list) => { + if (!cancelled) setReactions(list ?? []); + }) + .catch(() => { + if (!cancelled) setReactions([]); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId, issueId, commentId]); + + // Close picker on outside click. + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setPickerOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [pickerOpen]); + + // Group by emoji, count, and remember if I reacted. + const grouped = new Map(); + for (const r of reactions) { + const cur = grouped.get(r.reaction) ?? { count: 0, mine: false }; + cur.count += 1; + if (currentUserId && r.actor_id === currentUserId) cur.mine = true; + grouped.set(r.reaction, cur); + } + + const toggle = async (emoji: string) => { + const existing = grouped.get(emoji); + setPickerOpen(false); + try { + if (existing?.mine) { + await commentService.removeReaction(workspaceSlug, projectId, issueId, commentId, emoji); + } else { + await commentService.addReaction(workspaceSlug, projectId, issueId, commentId, emoji); + } + // Refetch — small list, simpler than reconciling locally. + const next = await commentService.listReactions(workspaceSlug, projectId, issueId, commentId); + setReactions(next ?? []); + } catch { + // best-effort; a missing reaction or network blip shouldn't disrupt the UX + } + }; + + if (grouped.size === 0 && !pickerOpen) { + return ( +
+ +
+ ); + } + + return ( +
+ {[...grouped.entries()].map(([emoji, info]) => ( + + ))} + + {pickerOpen && ( +
+ {QUICK_EMOJIS.map((e) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/work-item/DescriptionEditor.tsx b/ui/src/components/work-item/DescriptionEditor.tsx new file mode 100644 index 00000000..8017a662 --- /dev/null +++ b/ui/src/components/work-item/DescriptionEditor.tsx @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { EditorContent, useEditor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Placeholder from '@tiptap/extension-placeholder'; +import Underline from '@tiptap/extension-underline'; +import { createMentionExtension, type MentionMember } from './editorMention'; +import { createSlashCommands } from './editorSlashCommands'; + +const SAVE_DEBOUNCE_MS = 1500; + +type SaveState = 'idle' | 'saving' | 'saved' | 'error'; + +export interface DescriptionEditorProps { + /** + * Initial HTML to seed the editor with. We snapshot this on first mount and + * subsequent changes update only via `editor.setContent` when the prop + * actually differs from the editor's current state. + */ + initialHtml: string; + /** Debounced auto-save callback. Receives HTML. */ + onSave: (html: string) => Promise | void; + /** Placeholder shown when empty. */ + placeholder?: string; + /** Disable editing (e.g. while loading). */ + disabled?: boolean; + /** Members available for @-mention. */ + mentionMembers?: MentionMember[]; +} + +/** + * Rich-text editor for the issue description. Auto-saves on debounced change + * so users don't need to remember to hit a Save button. + * + * Pattern matches Plane's `DescriptionInput` (TipTap + 1.5s debounce + status + * indicator). The toolbar is a simplified version of CommentEditor's. + */ +export function DescriptionEditor({ + initialHtml, + onSave, + placeholder = 'Add a description…', + disabled = false, + mentionMembers, +}: DescriptionEditorProps) { + const membersRef = useRef(mentionMembers ?? []); + useEffect(() => { + membersRef.current = mentionMembers ?? []; + }, [mentionMembers]); + // The getter is only called inside TipTap's suggestion lifecycle (event-driven, + // not during render). The lint rule below can't see that, so we silence it. + const getMembers = useCallback(() => membersRef.current, []); + const [saveState, setSaveState] = useState('idle'); + const lastSavedRef = useRef(normalize(initialHtml)); + const debounceTimer = useRef | null>(null); + + const flushSave = useCallback( + async (html: string) => { + const normalized = normalize(html); + if (normalized === lastSavedRef.current) return; + setSaveState('saving'); + try { + await onSave(html); + lastSavedRef.current = normalized; + setSaveState('saved'); + } catch { + setSaveState('error'); + } + }, + [onSave], + ); + + const mentionExt = useMemo(() => createMentionExtension(getMembers), [getMembers]); + const slashExt = useMemo(() => createSlashCommands(), []); + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + bulletList: { keepMarks: true, keepAttributes: true }, + orderedList: { keepMarks: true, keepAttributes: true }, + codeBlock: {}, + }), + Underline, + Placeholder.configure({ placeholder }), + mentionExt, + slashExt, + ], + content: initialHtml || '', + editable: !disabled, + onUpdate: ({ editor: e }) => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + const html = e.getHTML(); + debounceTimer.current = setTimeout(() => { + void flushSave(html); + }, SAVE_DEBOUNCE_MS); + }, + onBlur: ({ editor: e }) => { + // Flush on blur so the user sees "Saved" before navigating away. + if (debounceTimer.current) clearTimeout(debounceTimer.current); + void flushSave(e.getHTML()); + }, + }); + + // Sync external changes (e.g. parent refetches description from the API). + useEffect(() => { + if (!editor) return; + const incoming = normalize(initialHtml); + if (incoming !== normalize(editor.getHTML()) && incoming !== lastSavedRef.current) { + editor.commands.setContent(initialHtml || ''); + lastSavedRef.current = incoming; + } + }, [editor, initialHtml]); + + // Tear down the editor on unmount. + useEffect(() => { + return () => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + editor?.destroy(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- editor identity is stable + }, []); + + // Keep editable in sync with disabled prop. + useEffect(() => { + editor?.setEditable(!disabled); + }, [editor, disabled]); + + if (!editor) return null; + + const buttonBase = + 'inline-flex h-7 w-7 items-center justify-center rounded border border-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-icon-secondary) disabled:opacity-40'; + + return ( +
+
+ + + + + + +
+ {saveState === 'saving' && 'Saving…'} + {saveState === 'saved' && 'Saved'} + {saveState === 'error' && ( + Failed to save + )} +
+
+
+ +
+
+ ); +} + +/** + * Normalize a description HTML so empty-equivalent forms compare equal. + * TipTap's empty doc serializes as `

`; we treat that, blank, and pure + * whitespace as the same value to avoid spurious save calls on focus. + */ +function normalize(html: string | undefined | null): string { + const s = (html ?? '').trim(); + if (s === '' || s === '

' || s === '


') return ''; + return s; +} diff --git a/ui/src/components/work-item/IssueActivityFeed.tsx b/ui/src/components/work-item/IssueActivityFeed.tsx new file mode 100644 index 00000000..06c5d7d2 --- /dev/null +++ b/ui/src/components/work-item/IssueActivityFeed.tsx @@ -0,0 +1,266 @@ +import { useMemo } from 'react'; +import { + CheckCircle2, + Calendar, + Flag, + Pencil, + PlusCircle, + Tag, + UserPlus, + UserMinus, + Link as LinkIcon, +} from 'lucide-react'; +import { Avatar } from '../ui'; +import { getImageUrl } from '../../lib/utils'; +import type { + IssueActivityApiResponse, + LabelApiResponse, + StateApiResponse, + WorkspaceMemberApiResponse, +} from '../../api/types'; + +interface IssueActivityFeedProps { + activities: IssueActivityApiResponse[]; + members: WorkspaceMemberApiResponse[]; + states: StateApiResponse[]; + labels: LabelApiResponse[]; +} + +/** + * Renders one row per IssueActivity. Each row is "icon + actor avatar + sentence + relative time". + * Mirrors Plane's per-field activity component dispatch but kept in a single + * function for compactness — extend the switch below to add new field types. + */ +export function IssueActivityFeed({ activities, members, states, labels }: IssueActivityFeedProps) { + const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); + const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); + const memberById = useMemo(() => new Map(members.map((m) => [m.member_id, m])), [members]); + + if (activities.length === 0) return null; + + return ( +
    + {activities.map((a) => { + const actor = a.actor_id ? memberById.get(a.actor_id) : null; + const actorName = memberLabel(actor); + const { icon, sentence } = renderActivity(a, stateById, labelById, memberById); + if (!sentence) return null; + return ( +
  • + + {icon} + + + + {actorName} {sentence} + + + {formatRelative(a.created_at)} + +
  • + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Per-field rendering — returns the icon + the descriptive sentence body. +// --------------------------------------------------------------------------- + +function renderActivity( + a: IssueActivityApiResponse, + stateById: Map, + labelById: Map, + memberById: Map, +): { icon: React.ReactNode; sentence: React.ReactNode | null } { + if (a.verb === 'created') { + return { icon: , sentence: <>created this work item }; + } + switch (a.field ?? '') { + case 'name': + return { + icon: , + sentence: ( + <> + renamed the work item to{' '} + {a.new_value || '—'} + + ), + }; + case 'state': + return { + icon: , + sentence: ( + <> + changed state from to{' '} + + + ), + }; + case 'priority': + return { + icon: , + sentence: ( + <> + set priority to{' '} + {a.new_value || 'none'} + + ), + }; + case 'start_date': + return { + icon: , + sentence: a.new_value ? ( + <> + set start date to {a.new_value} + + ) : ( + <>cleared the start date + ), + }; + case 'target_date': + return { + icon: , + sentence: a.new_value ? ( + <> + set due date to {a.new_value} + + ) : ( + <>cleared the due date + ), + }; + case 'parent': + return { + icon: , + sentence: a.new_value ? <>set the parent work item : <>removed the parent work item, + }; + case 'assignees_added': + return { + icon: , + sentence: ( + <> + assigned + + ), + }; + case 'assignees_removed': + return { + icon: , + sentence: ( + <> + unassigned + + ), + }; + case 'labels_added': + return { + icon: , + sentence: ( + <> + added label + + ), + }; + case 'labels_removed': + return { + icon: , + sentence: ( + <> + removed label + + ), + }; + default: + return { icon: , sentence: null }; + } +} + +function StateRef({ + id, + stateById, +}: { + id?: string | null; + stateById: Map; +}) { + if (!id) return ; + const s = stateById.get(id); + if (!s) return {id.slice(0, 8)}; + return ( + + + {s.name} + + ); +} + +function LabelRef({ + id, + labelById, +}: { + id?: string | null; + labelById: Map; +}) { + if (!id) return ; + const l = labelById.get(id); + if (!l) return {id.slice(0, 8)}; + return ( + + + {l.name} + + ); +} + +function MemberRef({ + id, + memberById, +}: { + id?: string | null; + memberById: Map; +}) { + if (!id) return someone; + const m = memberById.get(id); + return @{memberLabel(m)}; +} + +function memberLabel(m: WorkspaceMemberApiResponse | null | undefined): string { + if (!m) return 'someone'; + const display = m.member_display_name?.trim(); + if (display) return display; + const email = m.member_email?.split('@')[0]?.trim(); + if (email) return email; + return 'someone'; +} + +function formatRelative(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + const now = Date.now(); + const seconds = Math.max(0, Math.floor((now - t) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + return `${months}mo`; +} diff --git a/ui/src/components/work-item/IssuePRBadge.tsx b/ui/src/components/work-item/IssuePRBadge.tsx new file mode 100644 index 00000000..ca502261 --- /dev/null +++ b/ui/src/components/work-item/IssuePRBadge.tsx @@ -0,0 +1,52 @@ +import { GitPullRequest, GitMerge } from 'lucide-react'; +import type { GitHubIssueSummaryEntry } from '../../api/types'; + +interface IssuePRBadgeProps { + summary?: GitHubIssueSummaryEntry; +} + +/** + * Compact icon shown next to an issue row when at least one PR is linked. + * Color follows the latest PR's state (open=green / merged=purple / closed=red), + * tooltip lists the breakdown. + */ +export function IssuePRBadge({ summary }: IssuePRBadgeProps) { + if (!summary || summary.total === 0) return null; + + const { color, icon } = badgeStyle(summary.latest_state); + const tooltip = buildTooltip(summary); + + return ( + + {icon} + + ); +} + +function badgeStyle(latest: string): { color: string; icon: React.ReactNode } { + if (latest === 'merged') { + return { color: '#8957e5', icon: }; + } + if (latest === 'closed') { + return { + color: '#cf222e', + icon: , + }; + } + return { color: '#1a7f37', icon: }; +} + +function buildTooltip(s: GitHubIssueSummaryEntry): string { + const parts: string[] = []; + parts.push(`${s.total} pull request${s.total === 1 ? '' : 's'}`); + if (s.open) parts.push(`${s.open} open`); + if (s.merged) parts.push(`${s.merged} merged`); + if (s.closed) parts.push(`${s.closed} closed`); + if (s.draft) parts.push(`${s.draft} draft`); + return parts.join(' · '); +} diff --git a/ui/src/components/work-item/IssuePRSidebar.tsx b/ui/src/components/work-item/IssuePRSidebar.tsx new file mode 100644 index 00000000..f9fc9c52 --- /dev/null +++ b/ui/src/components/work-item/IssuePRSidebar.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useState } from 'react'; +import { GitPullRequest, GitMerge, X, Loader2, Plus } from 'lucide-react'; +import { Card, CardContent, CardHeader, Button } from '../ui'; +import { integrationService } from '../../services/integrationService'; +import { getApiErrorMessage } from '../../api/client'; +import type { GitHubIssueLinkResponse } from '../../api/types'; + +interface IssuePRSidebarProps { + workspaceSlug: string; + projectId: string; + issueId: string; +} + +/** + * Right-rail panel on the issue detail page listing every GitHub PR linked to + * this issue. Auto-detected refs and manually-pasted URLs both land here. + * + * Features: + * - One row per PR with state-coloured icon, owner/repo#num link, title, + * author + relative time, ✕ to unlink. + * - Footer: collapsible "Link a pull request" form with a single URL input. + * + * On 404 from the project (no repo linked yet) we render a soft "Link a repo + * first" message rather than the form. + */ +export function IssuePRSidebar({ workspaceSlug, projectId, issueId }: IssuePRSidebarProps) { + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [showForm, setShowForm] = useState(false); + const [url, setUrl] = useState(''); + const [adding, setAdding] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(''); + integrationService + .githubListIssueLinks(workspaceSlug, projectId, issueId) + .then((list) => { + if (!cancelled) setLinks(list ?? []); + }) + .catch((e) => { + if (!cancelled) setError(getApiErrorMessage(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId, issueId]); + + const handleAdd = async () => { + const v = url.trim(); + if (!v) return; + setAdding(true); + setError(''); + try { + const created = await integrationService.githubCreateIssueLink( + workspaceSlug, + projectId, + issueId, + v, + ); + // De-dup by id — the upsert path may return an existing row. + setLinks((prev) => [created, ...prev.filter((l) => l.id !== created.id)]); + setUrl(''); + setShowForm(false); + } catch (e) { + setError(getApiErrorMessage(e)); + } finally { + setAdding(false); + } + }; + + const handleUnlink = async (linkId: string) => { + setError(''); + try { + await integrationService.githubDeleteIssueLink(workspaceSlug, projectId, issueId, linkId); + setLinks((prev) => prev.filter((l) => l.id !== linkId)); + } catch (e) { + setError(getApiErrorMessage(e)); + } + }; + + return ( + + + Linked pull requests + {!showForm && ( + + )} + + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Loading PRs… +
+ ) : links.length === 0 && !showForm ? ( +

+ No pull requests yet. They appear automatically when a PR references this issue (e.g. + Fixes DEV-42), or you can paste a URL above. +

+ ) : ( +
    + {links.map((l) => ( + void handleUnlink(l.id)} /> + ))} +
+ )} + + {showForm && ( +
+ + setUrl(e.target.value)} + placeholder="https://github.com/owner/repo/pull/123" + className="block w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1.5 text-xs text-(--txt-primary) focus:outline-none" + disabled={adding} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleAdd(); + if (e.key === 'Escape') { + setShowForm(false); + setUrl(''); + } + }} + autoFocus + /> +
+ + +
+
+ )} +
+
+ ); +} + +function PRRow({ link, onUnlink }: { link: GitHubIssueLinkResponse; onUnlink: () => void }) { + const repoLabel = useMemo(() => { + // issue_url is the canonical PR URL: https://github.com/{owner}/{repo}/pull/{n} + try { + const u = new URL(link.issue_url); + const parts = u.pathname.split('/').filter(Boolean); + if (parts.length >= 4) { + return `${parts[0]}/${parts[1]}#${link.repo_issue_id}`; + } + } catch { + /* ignore */ + } + return `#${link.repo_issue_id}`; + }, [link.issue_url, link.repo_issue_id]); + + const stateMeta = prStateMeta(link); + + return ( +
  • + + {stateMeta.icon} + +
    + + {repoLabel} + {link.title ? ( + + {link.title} + + ) : null} + +
    + {stateMeta.label} + {link.author_login && · @{link.author_login}} + {link.detection_source === 'manual' && · manual} +
    +
    + +
  • + ); +} + +function prStateMeta(link: GitHubIssueLinkResponse): { + color: string; + label: string; + icon: React.ReactNode; +} { + if (link.state === 'merged') { + return { color: '#8957e5', label: 'Merged', icon: }; + } + if (link.state === 'closed') { + return { + color: '#cf222e', + label: 'Closed', + icon: , + }; + } + if (link.draft) { + return { color: '#6e7781', label: 'Draft', icon: }; + } + return { color: '#1a7f37', label: 'Open', icon: }; +} diff --git a/ui/src/components/work-item/IssueRowCells.tsx b/ui/src/components/work-item/IssueRowCells.tsx new file mode 100644 index 00000000..1d59c507 --- /dev/null +++ b/ui/src/components/work-item/IssueRowCells.tsx @@ -0,0 +1,332 @@ +import { useMemo } from 'react'; +import { AlertTriangle, Calendar, Minus, SignalHigh, SignalLow, SignalMedium } from 'lucide-react'; +import { Avatar } from '../ui'; +import { cn, getImageUrl } from '../../lib/utils'; +import type { IssueApiResponse, LabelApiResponse, StateApiResponse } from '../../api/types'; +import type { Priority } from '../../types'; +import type { MemberLite } from '../../lib/issueRowHelpers'; + +export type { MemberLite }; + +// --------------------------------------------------------------------------- +// State pill — color dot + name. Color comes from the state's stored color +// (hex or CSS var). We dim the background to a 12% wash so the pill fits on +// any theme without clashing with the row hover. +// --------------------------------------------------------------------------- + +interface StatePillProps { + state?: StateApiResponse | null; + size?: 'sm' | 'md'; +} + +export function StatePill({ state, size = 'sm' }: StatePillProps) { + if (!state) { + return ( + + + No state + + ); + } + const color = normalizeHex(state.color) || '#6b7280'; + const wash = withAlpha(color, 0.12); + const padding = size === 'md' ? 'px-2' : 'px-1.5'; + const height = size === 'md' ? 'h-6' : 'h-5'; + return ( + + + {state.name} + + ); +} + +// --------------------------------------------------------------------------- +// Priority icon — 5 levels with distinct icons. Linear-style: the icon itself +// carries the meaning; color reinforces it. +// --------------------------------------------------------------------------- + +const PRIORITY_META: Record< + Priority, + { icon: React.ReactNode; color: string; label: string; bg: string } +> = { + urgent: { + icon: , + color: '#dc2626', + label: 'Urgent', + bg: 'rgba(220,38,38,0.12)', + }, + high: { + icon: , + color: '#ea580c', + label: 'High', + bg: 'rgba(234,88,12,0.12)', + }, + medium: { + icon: , + color: '#ca8a04', + label: 'Medium', + bg: 'rgba(202,138,4,0.12)', + }, + low: { + icon: , + color: '#2563eb', + label: 'Low', + bg: 'rgba(37,99,235,0.12)', + }, + none: { + icon: , + color: '#6b7280', + label: 'No priority', + bg: 'transparent', + }, +}; + +interface PriorityIconProps { + priority?: Priority | string | null; + /** "icon" = just the symbol; "pill" = symbol with background. */ + variant?: 'icon' | 'pill'; +} + +export function PriorityIcon({ priority, variant = 'pill' }: PriorityIconProps) { + const key = (priority ?? 'none') as Priority; + const meta = PRIORITY_META[key] ?? PRIORITY_META.none; + if (variant === 'icon') { + return ( + + {meta.icon} + + ); + } + return ( + + {meta.icon} + + ); +} + +// --------------------------------------------------------------------------- +// Avatar group — overlapping stack with a +N overflow chip. +// --------------------------------------------------------------------------- + +interface AvatarGroupProps { + members: MemberLite[]; + max?: number; +} + +export function WorkItemAvatarGroup({ members, max = 3 }: AvatarGroupProps) { + if (members.length === 0) { + return ( + + + + ); + } + const visible = members.slice(0, max); + const overflow = members.length - visible.length; + return ( + m.name).join(', ')} + > + {visible.map((m, i) => ( + 0 && '-ml-1.5')} + /> + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + ); +} + +const UserDashedIcon = () => ( + + + + +); + +// --------------------------------------------------------------------------- +// Label chips — up to N visible with color dots + names; "+M" pill for overflow. +// --------------------------------------------------------------------------- + +interface LabelChipsProps { + labels: LabelApiResponse[]; + max?: number; +} + +export function LabelChips({ labels, max = 2 }: LabelChipsProps) { + if (labels.length === 0) { + return null; + } + const visible = labels.slice(0, max); + const overflow = labels.length - visible.length; + return ( + + {visible.map((l) => ( + + + {l.name} + + ))} + {overflow > 0 && ( + l.name) + .join(', ')} + > + +{overflow} + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Due-date cell — date next to icon, red when overdue (target < today and the +// issue isn't already in a completed/cancelled state). +// --------------------------------------------------------------------------- + +interface DueDateCellProps { + issue: Pick; + state?: StateApiResponse | null; + /** + * Reference timestamp used to decide overdue. Hoisted to a prop so this + * component is pure (the parent computes it once per render with `useNow()`). + */ + now: number; +} + +export function DueDateCell({ issue, state, now }: DueDateCellProps) { + const overdue = useMemo(() => { + if (!issue.target_date) return false; + const t = Date.parse(issue.target_date); + if (Number.isNaN(t)) return false; + const stateGroup = state?.group ?? ''; + if (stateGroup === 'completed' || stateGroup === 'cancelled') return false; + return t < now - 24 * 3600 * 1000; + }, [issue.target_date, state?.group, now]); + + if (!issue.target_date) { + return ( + + + + + ); + } + return ( + + + {formatShort(issue.target_date, now)} + + ); +} + +// --------------------------------------------------------------------------- +// Helpers (private) +// --------------------------------------------------------------------------- + +function formatShort(iso: string, now: number): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + const d = new Date(t); + // "Mar 5" — local short format, no year unless distant. + const sameYear = d.getFullYear() === new Date(now).getFullYear(); + return d.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: 'numeric' }), + }); +} + +function normalizeHex(input?: string | null): string { + if (!input) return ''; + const s = input.trim(); + if (s.startsWith('#') && (s.length === 4 || s.length === 7)) return s; + if (/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(s)) return `#${s}`; + return s; // fall through (CSS var, rgba, etc.) +} + +function withAlpha(input: string, a: number): string { + // Accepts only #rrggbb / #rgb hex; otherwise return input untouched. + const s = input.trim(); + if (!s.startsWith('#')) return s; + const hex = s.length === 4 ? expandShortHex(s) : s; + if (hex.length !== 7) return s; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +function expandShortHex(s: string): string { + return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`; +} diff --git a/ui/src/components/work-item/editorMention.ts b/ui/src/components/work-item/editorMention.ts new file mode 100644 index 00000000..c90d75ad --- /dev/null +++ b/ui/src/components/work-item/editorMention.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- TipTap suggestion lifecycle uses untyped hooks */ +import Mention from '@tiptap/extension-mention'; +import type { Editor } from '@tiptap/core'; + +export interface MentionMember { + id: string; + label: string; +} + +/** + * TipTap @-mention extension with a minimal vanilla-DOM suggestion popup. + * + * Why vanilla DOM and not a React portal: the TipTap Suggestion plugin's + * `render()` returns lifecycle callbacks driven by the editor's transaction + * loop. A React-portal-based popup would need a separate root re-mount on + * every transaction; the DOM popup is simpler and keeps the component file + * focused. Keyboard nav (↑↓ + Enter) wired below; Escape closes. + * + * Inserts a styled `@Name` node — read by + * editor.getHTML() and rendered as text in the comment thread. + */ +export function createMentionExtension(getMembers: () => MentionMember[]) { + return Mention.configure({ + HTMLAttributes: { + class: 'rounded bg-(--bg-accent-subtle) px-1 py-0.5 text-(--txt-accent-primary) font-medium', + }, + suggestion: { + char: '@', + items: ({ query }) => { + const q = query.toLowerCase().trim(); + const members = getMembers(); + if (!q) return members.slice(0, 8); + return members.filter((m) => m.label.toLowerCase().includes(q)).slice(0, 8); + }, + render: () => { + let popup: HTMLDivElement | null = null; + let cleanupClick: (() => void) | null = null; + let selectedIndex = 0; + let currentItems: MentionMember[] = []; + let currentCommand: ((item: { id: string; label: string }) => void) | null = null; + + const renderPopup = () => { + if (!popup) return; + popup.innerHTML = ''; + if (currentItems.length === 0) { + const empty = document.createElement('div'); + empty.textContent = 'No members'; + empty.className = 'px-3 py-2 text-xs text-(--txt-tertiary)'; + popup.appendChild(empty); + return; + } + currentItems.forEach((item, i) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = item.label; + btn.className = `block w-full px-3 py-1.5 text-left text-sm ${ + i === selectedIndex + ? 'bg-(--bg-accent-subtle) text-(--txt-accent-primary)' + : 'text-(--txt-primary) hover:bg-(--bg-layer-1-hover)' + }`; + btn.addEventListener('mousedown', (e) => { + e.preventDefault(); + currentCommand?.({ id: item.id, label: item.label }); + }); + popup!.appendChild(btn); + }); + }; + + const positionPopup = (clientRect: () => DOMRect | null) => { + if (!popup) return; + const rect = clientRect(); + if (!rect) return; + const margin = 4; + popup.style.left = `${Math.round(rect.left)}px`; + popup.style.top = `${Math.round(rect.bottom + margin)}px`; + }; + + return { + onStart: (props: any) => { + currentItems = props.items; + currentCommand = props.command; + selectedIndex = 0; + + popup = document.createElement('div'); + popup.className = + 'fixed z-(--z-modal) min-w-[180px] max-h-72 overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)'; + popup.style.position = 'fixed'; + popup.style.zIndex = '9999'; + document.body.appendChild(popup); + renderPopup(); + positionPopup(props.clientRect); + + const onClick = (e: MouseEvent) => { + if (popup && !popup.contains(e.target as Node)) { + cleanup(); + } + }; + document.addEventListener('mousedown', onClick); + cleanupClick = () => document.removeEventListener('mousedown', onClick); + }, + onUpdate: (props: any) => { + currentItems = props.items; + currentCommand = props.command; + if (selectedIndex >= currentItems.length) selectedIndex = 0; + renderPopup(); + positionPopup(props.clientRect); + }, + onKeyDown: (props: any) => { + const e = props.event as KeyboardEvent; + if (currentItems.length === 0) return false; + if (e.key === 'ArrowDown') { + selectedIndex = (selectedIndex + 1) % currentItems.length; + renderPopup(); + return true; + } + if (e.key === 'ArrowUp') { + selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length; + renderPopup(); + return true; + } + if (e.key === 'Enter') { + const item = currentItems[selectedIndex]; + if (item) { + currentCommand?.({ id: item.id, label: item.label }); + return true; + } + } + if (e.key === 'Escape') { + cleanup(); + return true; + } + return false; + }, + onExit: () => cleanup(), + }; + + function cleanup() { + if (cleanupClick) { + cleanupClick(); + cleanupClick = null; + } + if (popup) { + popup.remove(); + popup = null; + } + } + }, + }, + }); +} + +/** Convenience: insert a mention programmatically (used by the toolbar shortcut). */ +export function insertMention(editor: Editor, member: MentionMember) { + editor + .chain() + .focus() + .insertContent({ + type: 'mention', + attrs: { id: member.id, label: member.label }, + }) + .insertContent(' ') + .run(); +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/ui/src/components/work-item/editorSlashCommands.ts b/ui/src/components/work-item/editorSlashCommands.ts new file mode 100644 index 00000000..e594d998 --- /dev/null +++ b/ui/src/components/work-item/editorSlashCommands.ts @@ -0,0 +1,231 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- TipTap suggestion lifecycle uses untyped hooks */ +import { Extension } from '@tiptap/core'; +import Suggestion from '@tiptap/suggestion'; +import type { Editor, Range } from '@tiptap/core'; + +interface SlashCommand { + title: string; + description: string; + icon: string; + command: (props: { editor: Editor; range: Range }) => void; +} + +const COMMANDS: SlashCommand[] = [ + { + title: 'Heading 1', + description: 'Big section heading', + icon: 'H1', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleHeading({ level: 1 }).run(), + }, + { + title: 'Heading 2', + description: 'Medium section heading', + icon: 'H2', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleHeading({ level: 2 }).run(), + }, + { + title: 'Heading 3', + description: 'Small section heading', + icon: 'H3', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleHeading({ level: 3 }).run(), + }, + { + title: 'Bullet list', + description: 'A simple bullet list', + icon: '••', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleBulletList().run(), + }, + { + title: 'Numbered list', + description: 'An ordered list', + icon: '1.', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleOrderedList().run(), + }, + { + title: 'Quote', + description: 'Block quote', + icon: '❝', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleBlockquote().run(), + }, + { + title: 'Code block', + description: 'Fenced code', + icon: '', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: 'Divider', + description: 'Horizontal rule', + icon: '—', + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).setHorizontalRule().run(), + }, +]; + +/** + * TipTap extension that opens a slash-command palette when the user types `/`. + * Like the @-mention popup it uses a vanilla DOM list — keyboard nav (↑↓ + Enter) + * is wired so users never have to reach for the mouse. + */ +export function createSlashCommands() { + return Extension.create({ + name: 'slashCommands', + addOptions() { + return { + suggestion: { + char: '/', + command: ({ editor, range, props }: any) => { + (props as SlashCommand).command({ editor, range }); + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + items: ({ query }) => { + const q = query.toLowerCase().trim(); + if (!q) return COMMANDS; + return COMMANDS.filter( + (c) => c.title.toLowerCase().includes(q) || c.description.toLowerCase().includes(q), + ); + }, + render: () => { + let popup: HTMLDivElement | null = null; + let cleanupClick: (() => void) | null = null; + let selectedIndex = 0; + let currentItems: SlashCommand[] = []; + let currentCommand: ((item: SlashCommand) => void) | null = null; + + const renderPopup = () => { + if (!popup) return; + popup.innerHTML = ''; + if (currentItems.length === 0) { + const empty = document.createElement('div'); + empty.textContent = 'No matches'; + empty.className = 'px-3 py-2 text-xs text-(--txt-tertiary)'; + popup.appendChild(empty); + return; + } + currentItems.forEach((cmd, i) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${ + i === selectedIndex + ? 'bg-(--bg-accent-subtle) text-(--txt-accent-primary)' + : 'text-(--txt-primary) hover:bg-(--bg-layer-1-hover)' + }`; + const icon = document.createElement('span'); + icon.className = + 'inline-flex h-5 w-5 items-center justify-center rounded border border-(--border-subtle) text-[10px] font-semibold text-(--txt-icon-secondary)'; + icon.textContent = cmd.icon; + btn.appendChild(icon); + const body = document.createElement('span'); + body.className = 'min-w-0 flex-1'; + const t = document.createElement('span'); + t.className = 'block truncate'; + t.textContent = cmd.title; + const d = document.createElement('span'); + d.className = 'block truncate text-[11px] text-(--txt-tertiary)'; + d.textContent = cmd.description; + body.appendChild(t); + body.appendChild(d); + btn.appendChild(body); + btn.addEventListener('mousedown', (e) => { + e.preventDefault(); + currentCommand?.(cmd); + }); + popup!.appendChild(btn); + }); + }; + + const positionPopup = (clientRect: () => DOMRect | null) => { + if (!popup) return; + const rect = clientRect(); + if (!rect) return; + popup.style.left = `${Math.round(rect.left)}px`; + popup.style.top = `${Math.round(rect.bottom + 4)}px`; + }; + + return { + onStart: (props: any) => { + currentItems = props.items as SlashCommand[]; + currentCommand = props.command as (item: SlashCommand) => void; + selectedIndex = 0; + popup = document.createElement('div'); + popup.className = + 'fixed min-w-[260px] max-h-72 overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)'; + popup.style.position = 'fixed'; + popup.style.zIndex = '9999'; + document.body.appendChild(popup); + renderPopup(); + positionPopup(props.clientRect); + + const onClick = (e: MouseEvent) => { + if (popup && !popup.contains(e.target as Node)) cleanup(); + }; + document.addEventListener('mousedown', onClick); + cleanupClick = () => document.removeEventListener('mousedown', onClick); + }, + onUpdate: (props: any) => { + currentItems = props.items as SlashCommand[]; + currentCommand = props.command as (item: SlashCommand) => void; + if (selectedIndex >= currentItems.length) selectedIndex = 0; + renderPopup(); + positionPopup(props.clientRect); + }, + onKeyDown: (props: any) => { + const e = props.event as KeyboardEvent; + if (currentItems.length === 0) return false; + if (e.key === 'ArrowDown') { + selectedIndex = (selectedIndex + 1) % currentItems.length; + renderPopup(); + return true; + } + if (e.key === 'ArrowUp') { + selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length; + renderPopup(); + return true; + } + if (e.key === 'Enter') { + const item = currentItems[selectedIndex]; + if (item) { + currentCommand?.(item); + return true; + } + } + if (e.key === 'Escape') { + cleanup(); + return true; + } + return false; + }, + onExit: () => cleanup(), + }; + + function cleanup() { + if (cleanupClick) { + cleanupClick(); + cleanupClick = null; + } + if (popup) { + popup.remove(); + popup = null; + } + } + }, + }), + ]; + }, + }); +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/ui/src/components/work-item/layouts/IssueLayoutBoard.tsx b/ui/src/components/work-item/layouts/IssueLayoutBoard.tsx new file mode 100644 index 00000000..6036d2f2 --- /dev/null +++ b/ui/src/components/work-item/layouts/IssueLayoutBoard.tsx @@ -0,0 +1,193 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { IssuePRBadge } from '../IssuePRBadge'; +import { + DueDateCell, + LabelChips, + PriorityIcon, + StatePill, + WorkItemAvatarGroup, +} from '../IssueRowCells'; +import { membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; +import type { IssueApiResponse, LabelApiResponse } from '../../../api/types'; +import type { Priority } from '../../../types'; +import type { IssueLayoutProps } from './IssueLayoutTypes'; + +/** + * Kanban board grouped by state. One column per state, ordered by `sequence`, + * cards reuse the same cells the list rows use. + * + * Issues with no state_id (or whose state was deleted) bucket into a synthetic + * "No state" column at the end. + */ +export function IssueLayoutBoard({ + project, + states, + labels, + members, + issues, + prSummary, + issueHref, + now, +}: IssueLayoutProps) { + const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); + const orderedStates = useMemo( + () => + [...states].sort( + (a, b) => (a.sequence ?? 0) - (b.sequence ?? 0) || a.name.localeCompare(b.name), + ), + [states], + ); + + const groups = useMemo(() => { + const buckets = new Map(); + for (const s of orderedStates) buckets.set(s.id, []); + const orphans: IssueApiResponse[] = []; + for (const issue of issues) { + if (issue.state_id && buckets.has(issue.state_id)) { + buckets.get(issue.state_id)!.push(issue); + } else { + orphans.push(issue); + } + } + return { buckets, orphans }; + }, [orderedStates, issues]); + + return ( +
    + {orderedStates.map((state) => { + const colIssues = groups.buckets.get(state.id) ?? []; + return ( + + {colIssues.map((issue) => ( + labelById.get(id)) + .filter((l): l is LabelApiResponse => Boolean(l))} + assignees={membersFromAssigneeIds(members, issue.assignee_ids ?? [])} + prSummary={prSummary[issue.id]} + href={issueHref(issue.id)} + now={now} + /> + ))} + {colIssues.length === 0 && ( +

    No work items

    + )} +
    + ); + })} + + {groups.orphans.length > 0 && ( + + {groups.orphans.map((issue) => ( + labelById.get(id)) + .filter((l): l is LabelApiResponse => Boolean(l))} + assignees={membersFromAssigneeIds(members, issue.assignee_ids ?? [])} + prSummary={prSummary[issue.id]} + href={issueHref(issue.id)} + now={now} + /> + ))} + + )} +
    + ); +} + +function BoardColumn({ + title, + color, + count, + children, +}: { + title: string; + color?: string | null; + count: number; + children: React.ReactNode; +}) { + return ( +
    +
    +

    {title}

    + {count} +
    +
    {children}
    +
    + ); +} + +interface BoardCardProps { + issue: IssueApiResponse; + project: IssueLayoutProps['project']; + state: IssueLayoutProps['states'][number] | null; + labels: LabelApiResponse[]; + assignees: ReturnType; + prSummary?: IssueLayoutProps['prSummary'][string]; + href: string; + now: number; +} + +function BoardCard({ + issue, + project, + state, + labels, + assignees, + prSummary, + href, + now, +}: BoardCardProps) { + const displayId = `${project.identifier ?? project.id.slice(0, 8)}-${issue.sequence_id ?? issue.id.slice(-4)}`; + return ( + +
    + +
    +
    + {displayId} + +
    +

    + {issue.name} +

    +
    +
    + +
    + {labels.length > 0 && } + + {state && } +
    + +
    + +
    + + ); +} diff --git a/ui/src/components/work-item/layouts/IssueLayoutCalendar.tsx b/ui/src/components/work-item/layouts/IssueLayoutCalendar.tsx new file mode 100644 index 00000000..25e1d2df --- /dev/null +++ b/ui/src/components/work-item/layouts/IssueLayoutCalendar.tsx @@ -0,0 +1,239 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { PriorityIcon } from '../IssueRowCells'; +import type { IssueApiResponse } from '../../../api/types'; +import type { Priority } from '../../../types'; +import type { IssueLayoutProps } from './IssueLayoutTypes'; + +const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const; +const MAX_PER_CELL = 3; + +/** + * Month-grid calendar. Issues placed on their `target_date` cell. + * + * Issues without a target_date are bucketed into a "No due date" footer panel + * so they aren't silently dropped. + * + * Navigation: prev/next month. "Today" jumps to current month. The "viewMonth" + * is local UI state so switching layout doesn't lose the user's place. + */ +export function IssueLayoutCalendar({ project, states, issues, issueHref, now }: IssueLayoutProps) { + const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date(now))); + + const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); + + // Issues bucketed by ISO date string (YYYY-MM-DD). + const issuesByDate = useMemo(() => { + const map = new Map(); + const undated: IssueApiResponse[] = []; + for (const issue of issues) { + if (!issue.target_date) { + undated.push(issue); + continue; + } + const key = isoDate(issue.target_date); + if (!key) { + undated.push(issue); + continue; + } + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(issue); + } + return { map, undated }; + }, [issues]); + + const cells = buildMonthCells(viewMonth); + + return ( +
    +
    + + +

    + {viewMonth.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })} +

    + +
    + + {/* Weekday header */} +
    + {WEEKDAYS.map((d) => ( +
    + {d} +
    + ))} + + {/* Day cells */} + {cells.map(({ date, inMonth }) => { + const key = isoDate(date.toISOString().slice(0, 10))!; + const dayIssues = issuesByDate.map.get(key) ?? []; + const isToday = sameDay(date, new Date(now)); + const visible = dayIssues.slice(0, MAX_PER_CELL); + const overflow = dayIssues.length - visible.length; + return ( +
    +
    + + {date.getDate()} + + {dayIssues.length > 0 && ( + {dayIssues.length} + )} +
    +
    + {visible.map((issue) => ( + + ))} + {overflow > 0 && ( +

    +{overflow} more

    + )} +
    +
    + ); + })} +
    + + {issuesByDate.undated.length > 0 && ( +
    + + No due date · {issuesByDate.undated.length} + +
      + {issuesByDate.undated.map((issue) => ( +
    • + + + {issue.name} + + {project.identifier ?? project.id.slice(0, 8)}- + {issue.sequence_id ?? issue.id.slice(-4)} + + +
    • + ))} +
    +
    + )} +
    + ); +} + +function CalendarPill({ + issue, + href, + state, +}: { + issue: IssueApiResponse; + href: string; + state: IssueLayoutProps['states'][number] | null; +}) { + const dotColor = state?.color || 'var(--neutral-500)'; + return ( + + + {issue.name} + + ); +} + +// ---------- date helpers ---------- + +function startOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +function addMonths(d: Date, n: number): Date { + return new Date(d.getFullYear(), d.getMonth() + n, 1); +} + +function isoDate(input: string | null | undefined): string | null { + if (!input) return null; + // Accept "YYYY-MM-DD" or full ISO. Truncate to date only. + const t = Date.parse(input); + if (Number.isNaN(t)) return null; + const d = new Date(t); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +/** + * Build a 6-week grid (42 cells) starting from the Monday on or before the + * 1st of the given month. Each cell knows whether it falls inside the month. + */ +function buildMonthCells(viewMonth: Date): { date: Date; inMonth: boolean }[] { + const first = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1); + // Monday-first week. Date#getDay() returns 0 (Sun) … 6 (Sat). + const offset = (first.getDay() + 6) % 7; + const start = new Date(first); + start.setDate(first.getDate() - offset); + + const cells: { date: Date; inMonth: boolean }[] = []; + for (let i = 0; i < 42; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + cells.push({ date: d, inMonth: d.getMonth() === viewMonth.getMonth() }); + } + return cells; +} diff --git a/ui/src/components/work-item/layouts/IssueLayoutGantt.tsx b/ui/src/components/work-item/layouts/IssueLayoutGantt.tsx new file mode 100644 index 00000000..a68d1976 --- /dev/null +++ b/ui/src/components/work-item/layouts/IssueLayoutGantt.tsx @@ -0,0 +1,252 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { PriorityIcon } from '../IssueRowCells'; +import type { Priority } from '../../../types'; +import type { IssueLayoutProps } from './IssueLayoutTypes'; + +const DAY_MS = 24 * 3600 * 1000; +const DAY_PX = 28; // width per day on the timeline; pannable, not zoomable yet + +/** + * Lightweight Gantt — horizontal timeline of bars positioned by start_date and + * target_date. Issues without both dates fall into a sidebar "Undated" list. + * + * Implementation notes: + * - We compute the visible window from min(start_date) to max(target_date) + * across all dated issues, with a one-week padding either side. That keeps + * the chart compact for short-running projects. + * - The user can shift the window by ±7 days with the prev/next controls. + * Real zoom + drag-to-reschedule are deferred. + * - Bar color comes from `state.color`. + * - Sidebar (left) shows id + name; the chart (right) is horizontally + * scrollable for projects whose range exceeds the viewport. + */ +export function IssueLayoutGantt({ project, states, issues, issueHref, now }: IssueLayoutProps) { + const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); + + const dated = useMemo( + () => issues.filter((i) => Boolean(i.start_date) && Boolean(i.target_date)), + [issues], + ); + const undated = useMemo(() => issues.filter((i) => !i.start_date || !i.target_date), [issues]); + + const [shiftDays, setShiftDays] = useState(0); + + const window = useMemo(() => { + if (dated.length === 0) { + const today = startOfDay(new Date(now)); + return { start: today.getTime(), end: today.getTime() + 21 * DAY_MS }; + } + let min = Infinity; + let max = -Infinity; + for (const i of dated) { + const s = parseDay(i.start_date!); + const e = parseDay(i.target_date!); + if (s !== null && s < min) min = s; + if (e !== null && e > max) max = e; + } + if (min === Infinity || max === -Infinity) { + const today = startOfDay(new Date(now)); + return { start: today.getTime(), end: today.getTime() + 21 * DAY_MS }; + } + const pad = 7 * DAY_MS; + return { start: min - pad + shiftDays * DAY_MS, end: max + pad + shiftDays * DAY_MS }; + }, [dated, now, shiftDays]); + + const totalDays = Math.max(1, Math.round((window.end - window.start) / DAY_MS) + 1); + const days = useMemo(() => { + const arr: number[] = []; + for (let i = 0; i < totalDays; i++) arr.push(window.start + i * DAY_MS); + return arr; + }, [window.start, totalDays]); + + const todayMs = startOfDay(new Date(now)).getTime(); + const todayOffset = Math.round((todayMs - window.start) / DAY_MS); + + return ( +
    +
    + + +

    + {fmtRange(window.start, window.end)} +

    + + + {dated.length} dated · {undated.length} undated + +
    + + {dated.length === 0 ? ( +

    + No work items have both a start and a target date. Add dates to plot bars on the timeline. +

    + ) : ( +
    +
    + {/* Sticky sidebar: id + name */} +
    +
    + Work item +
    +
      + {dated.map((issue) => ( +
    • + + + + {project.identifier ?? project.id.slice(0, 8)}- + {issue.sequence_id ?? issue.id.slice(-4)} + + {issue.name} + +
    • + ))} +
    +
    + + {/* Timeline */} +
    + {/* Day-cell header */} +
    + {days.map((ms, i) => { + const d = new Date(ms); + const isMonthStart = d.getDate() === 1 || i === 0; + return ( +
    + {isMonthStart && ( + + {d.toLocaleDateString(undefined, { month: 'short' })} + + )} + {d.getDate()} +
    + ); + })} +
    + + {/* Today line */} + {todayOffset >= 0 && todayOffset < totalDays && ( +
    + )} + + {/* Bars */} +
      + {dated.map((issue) => { + const start = parseDay(issue.start_date!) ?? window.start; + const end = parseDay(issue.target_date!) ?? start; + const offset = Math.max(0, Math.round((start - window.start) / DAY_MS)); + const span = Math.max(1, Math.round((end - start) / DAY_MS) + 1); + const state = issue.state_id ? (stateById.get(issue.state_id) ?? null) : null; + const color = state?.color || '#6b7280'; + return ( +
    • + + {issue.name} + +
    • + ); + })} +
    +
    +
    +
    + )} + + {undated.length > 0 && ( +
    + + Undated · {undated.length} + +
      + {undated.map((issue) => ( +
    • + + + {issue.name} + + {issue.start_date ? '' : 'no start · '} + {issue.target_date ? '' : 'no target'} + + +
    • + ))} +
    +
    + )} +
    + ); +} + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function parseDay(input: string): number | null { + const t = Date.parse(input); + if (Number.isNaN(t)) return null; + return startOfDay(new Date(t)).getTime(); +} + +function fmtRange(start: number, end: number): string { + const s = new Date(start); + const e = new Date(end); + const sameYear = s.getFullYear() === e.getFullYear(); + const sStr = s.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: 'numeric' }), + }); + const eStr = e.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + return `${sStr} – ${eStr}`; +} diff --git a/ui/src/components/work-item/layouts/IssueLayoutList.tsx b/ui/src/components/work-item/layouts/IssueLayoutList.tsx new file mode 100644 index 00000000..830c8c5b --- /dev/null +++ b/ui/src/components/work-item/layouts/IssueLayoutList.tsx @@ -0,0 +1,162 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { IssuePRBadge } from '../IssuePRBadge'; +import { + DueDateCell, + LabelChips, + PriorityIcon, + StatePill, + WorkItemAvatarGroup, +} from '../IssueRowCells'; +import { membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; +import type { IssueApiResponse, LabelApiResponse } from '../../../api/types'; +import type { Priority } from '../../../types'; +import type { GroupedIssuesResult } from '../../../lib/issueListGroupAndSort'; +import type { IssueLayoutProps } from './IssueLayoutTypes'; + +interface IssueLayoutListProps extends IssueLayoutProps { + /** Pre-built grouping result from the parent (state/priority/cycle/etc. groupings). */ + groupedIssues: GroupedIssuesResult; + /** + * Filter columns (display properties) — true means render. Accepts the same + * narrow `SavedViewDisplayPropertyId` keys the parent's `hasCol` checks; we + * cast at the call site since this component only checks a known subset. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- column key narrowing lives in the parent + hasCol: (key: any) => boolean; + /** Whether to render empty groups (display option). */ + showEmptyGroups: boolean; + /** Sub-issue counts keyed by parent issue id. */ + subWorkCountByParentId: Map; + /** Resolves the issue's first cycle to a display name; '—' when none. */ + cycleName: (issue: IssueApiResponse) => string; + /** Resolves the issue's first module to a display name; '—' when none. */ + moduleName: (issue: IssueApiResponse) => string; +} + +export function IssueLayoutList({ + project, + states, + labels, + members, + prSummary, + issueHref, + now, + groupedIssues, + hasCol, + showEmptyGroups, + subWorkCountByParentId, + cycleName, + moduleName, +}: IssueLayoutListProps) { + const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); + const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); + + const renderRow = (issue: IssueApiResponse) => { + const displayId = `${project.identifier ?? project.id.slice(0, 8)}-${issue.sequence_id ?? issue.id.slice(-4)}`; + const subN = subWorkCountByParentId.get(issue.id) ?? 0; + const issueState = issue.state_id ? (stateById.get(issue.state_id) ?? null) : null; + const issueLabels = (issue.label_ids ?? []) + .map((id) => labelById.get(id)) + .filter((l): l is LabelApiResponse => Boolean(l)); + const issueAssignees = membersFromAssigneeIds(members, issue.assignee_ids ?? []); + const prInfo = prSummary[issue.id]; + const startStr = formatShort(issue.start_date); + + return ( +
  • + + + {hasCol('priority') ? ( + + + + ) : null} + {hasCol('id') ? ( + {displayId} + ) : null} + {issue.name} + + +
    + {hasCol('state') ? : null} + {hasCol('start_date') ? ( + + {startStr ?? '—'} + + ) : null} + {hasCol('due_date') ? : null} + {hasCol('assignee') ? : null} + {hasCol('labels') ? : null} + {hasCol('sub_work_count') && subN > 0 ? ( + + {subN} + + ) : null} + {hasCol('cycle') && cycleName(issue) !== '—' ? ( + + {cycleName(issue)} + + ) : null} + {hasCol('module') && moduleName(issue) !== '—' ? ( + + {moduleName(issue)} + + ) : null} +
    + +
  • + ); + }; + + if (groupedIssues.isFlat) { + return ( +
      + {(groupedIssues.groups.get(groupedIssues.order[0]) ?? []).map((issue) => renderRow(issue))} +
    + ); + } + + return ( +
    + {groupedIssues.order.map((sectionKey) => { + const sectionIssues = groupedIssues.groups.get(sectionKey) ?? []; + if (sectionIssues.length === 0 && !showEmptyGroups) return null; + const title = groupedIssues.title(sectionKey); + return ( +
    +

    + {title} + {sectionIssues.length} +

    +
      + {sectionIssues.map((issue) => renderRow(issue))} +
    +
    + ); + })} +
    + ); +} + +function formatShort(iso: string | null | undefined): string | null { + if (!iso?.trim()) return null; + const t = Date.parse(iso); + if (Number.isNaN(t)) return null; + return new Date(t).toLocaleDateString(); +} diff --git a/ui/src/components/work-item/layouts/IssueLayoutSpreadsheet.tsx b/ui/src/components/work-item/layouts/IssueLayoutSpreadsheet.tsx new file mode 100644 index 00000000..20cd6bc9 --- /dev/null +++ b/ui/src/components/work-item/layouts/IssueLayoutSpreadsheet.tsx @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { IssuePRBadge } from '../IssuePRBadge'; +import { + DueDateCell, + LabelChips, + PriorityIcon, + StatePill, + WorkItemAvatarGroup, +} from '../IssueRowCells'; +import { membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; +import type { LabelApiResponse } from '../../../api/types'; +import type { Priority } from '../../../types'; +import type { IssueLayoutProps } from './IssueLayoutTypes'; + +/** + * Spreadsheet layout — flat HTML table with sticky header. Each column reuses + * the same cells the list/board use, so visuals stay consistent. + * + * Columns (in order): ID • Title • State • Priority • Assignees • Labels • Due • Start. + * No grouping, no inline editing yet — those would each merit their own pass. + */ +export function IssueLayoutSpreadsheet({ + project, + states, + labels, + members, + issues, + prSummary, + issueHref, + now, +}: IssueLayoutProps) { + const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); + const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); + + return ( +
    + + + + + + + + + + + + + + + {issues.map((issue) => { + const displayId = `${project.identifier ?? project.id.slice(0, 8)}-${issue.sequence_id ?? issue.id.slice(-4)}`; + const issueState = issue.state_id ? (stateById.get(issue.state_id) ?? null) : null; + const issueLabels = (issue.label_ids ?? []) + .map((id) => labelById.get(id)) + .filter((l): l is LabelApiResponse => Boolean(l)); + const issueAssignees = membersFromAssigneeIds(members, issue.assignee_ids ?? []); + const startStr = formatShort(issue.start_date); + + return ( + + + + + + + + + + + ); + })} + +
    IDTitleState!AssigneesLabelsDueStart
    + + {displayId} + + + + {issue.name} + + + {issueState ? : null} + + + + + + + + {startStr ?? '—'}
    +
    + ); +} + +function Th({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +function Td({ children, className }: { children: React.ReactNode; className?: string }) { + return {children}; +} + +function formatShort(iso: string | null | undefined): string | null { + if (!iso?.trim()) return null; + const t = Date.parse(iso); + if (Number.isNaN(t)) return null; + return new Date(t).toLocaleDateString(); +} diff --git a/ui/src/components/work-item/layouts/IssueLayoutTypes.ts b/ui/src/components/work-item/layouts/IssueLayoutTypes.ts new file mode 100644 index 00000000..837a45b4 --- /dev/null +++ b/ui/src/components/work-item/layouts/IssueLayoutTypes.ts @@ -0,0 +1,45 @@ +import type { + GitHubIssueSummaryEntry, + IssueApiResponse, + LabelApiResponse, + ProjectApiResponse, + StateApiResponse, + WorkspaceMemberApiResponse, +} from '../../../api/types'; + +/** Available layout keys. Mirrors Plane's EIssueLayoutTypes. */ +export const ISSUE_LAYOUTS = ['list', 'board', 'spreadsheet', 'calendar', 'gantt'] as const; +export type IssueLayout = (typeof ISSUE_LAYOUTS)[number]; + +export function parseIssueLayout( + raw: string | null | undefined, + fallback: IssueLayout = 'list', +): IssueLayout { + if (!raw) return fallback; + return (ISSUE_LAYOUTS as readonly string[]).includes(raw) ? (raw as IssueLayout) : fallback; +} + +/** + * Shared props every layout receives. Computed once in the parent page so + * every layout sees consistent maps + the same "now" sample. + */ +export interface IssueLayoutProps { + workspaceSlug: string; + project: ProjectApiResponse; + /** Filtered, ordered issues — what the current view should display. */ + issues: IssueApiResponse[]; + /** All visible states for the project (for column headers, pickers, etc.). */ + states: StateApiResponse[]; + /** Project labels indexed for quick chip rendering. */ + labels: LabelApiResponse[]; + /** Workspace members (for assignee avatars). */ + members: WorkspaceMemberApiResponse[]; + /** github_issue_syncs aggregate per issue id. */ + prSummary: Record; + /** `${workspace}/projects/${project}` — used to build issue links. */ + baseUrl: string; + /** `${baseUrl}/issues/${id}` href builder. */ + issueHref: (id: string) => string; + /** Stable per-render "now" timestamp (ms) used by date cells. */ + now: number; +} diff --git a/ui/src/lib/issueRowHelpers.ts b/ui/src/lib/issueRowHelpers.ts new file mode 100644 index 00000000..da9f71aa --- /dev/null +++ b/ui/src/lib/issueRowHelpers.ts @@ -0,0 +1,27 @@ +import type { WorkspaceMemberApiResponse } from '../api/types'; + +export interface MemberLite { + id: string; + name: string; + avatarUrl?: string | null; +} + +/** Translate workspace members + assignee ids into a list of MemberLite. */ +export function membersFromAssigneeIds( + members: WorkspaceMemberApiResponse[], + assigneeIds?: string[], +): MemberLite[] { + if (!assigneeIds?.length || !members.length) return []; + const byId = new Map(members.map((m) => [m.member_id, m])); + const out: MemberLite[] = []; + for (const id of assigneeIds) { + const m = byId.get(id); + if (!m) continue; + out.push({ + id, + name: m.member_display_name || (m.member_email ?? 'Unknown'), + avatarUrl: m.member_avatar ?? null, + }); + } + return out; +} diff --git a/ui/src/pages/BoardPage.tsx b/ui/src/pages/BoardPage.tsx index 53165e31..487bcc9d 100644 --- a/ui/src/pages/BoardPage.tsx +++ b/ui/src/pages/BoardPage.tsx @@ -1,142 +1,17 @@ -import { useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; -import { Card, CardContent, Badge } from '../components/ui'; -import { workspaceService } from '../services/workspaceService'; -import { projectService } from '../services/projectService'; -import { issueService } from '../services/issueService'; -import { stateService } from '../services/stateService'; -import type { - WorkspaceApiResponse, - ProjectApiResponse, - IssueApiResponse, - StateApiResponse, -} from '../api/types'; -import type { Priority } from '../types'; - -const priorityVariant: Record = { - urgent: 'danger', - high: 'danger', - medium: 'warning', - low: 'default', - none: 'neutral', -}; +import { Navigate, useParams } from 'react-router-dom'; +/** + * Legacy `/board` route. The kanban now renders inside the issues page via + * `?layout=board`, so this page just redirects users (and bookmarks) to the + * canonical URL. + */ export function BoardPage() { const { workspaceSlug, projectId } = useParams<{ workspaceSlug: string; projectId: string; }>(); - const [workspace, setWorkspace] = useState(null); - const [project, setProject] = useState(null); - const [issues, setIssues] = useState([]); - const [states, setStates] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!workspaceSlug || !projectId) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) - setLoading(false); - return; - } - let cancelled = false; - setLoading(true); - Promise.all([ - workspaceService.getBySlug(workspaceSlug), - projectService.get(workspaceSlug, projectId), - issueService.list(workspaceSlug, projectId, { limit: 100 }), - stateService.list(workspaceSlug, projectId), - ]) - .then(([w, p, iss, st]) => { - if (!cancelled) { - setWorkspace(w ?? null); - setProject(p ?? null); - setIssues(iss ?? []); - setStates(st ?? []); - } - }) - .catch(() => { - if (!cancelled) { - setWorkspace(null); - setProject(null); - setIssues([]); - setStates([]); - } - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [workspaceSlug, projectId]); - - if (loading) { - return ( -
    - Loading… -
    - ); + if (!workspaceSlug || !projectId) { + return ; } - if (!workspace || !project) { - return
    Project not found.
    ; - } - - const baseUrl = `/${workspace.slug}/projects/${project.id}`; - const issuesByState = states.map((state) => ({ - state, - issues: issues.filter((i) => i.state_id === state.id), - })); - - return ( -
    -

    Board

    -
    - {issuesByState.map(({ state, issues: stateIssues }) => ( -
    -
    -

    {state.name}

    -

    - {stateIssues.length} issue{stateIssues.length !== 1 ? 's' : ''} -

    -
    -
    - {stateIssues.map((issue) => ( - - - -

    {issue.name}

    -
    - - {issue.priority ?? '—'} - -
    -
    -
    - - ))} - {stateIssues.length === 0 && ( -

    No issues

    - )} -
    -
    - ))} -
    -
    - ); + return ; } diff --git a/ui/src/pages/IssueDetailPage.tsx b/ui/src/pages/IssueDetailPage.tsx index 8bfb69c9..c2b078ef 100644 --- a/ui/src/pages/IssueDetailPage.tsx +++ b/ui/src/pages/IssueDetailPage.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; -import { Badge, Button, Card, CardContent, CardHeader, Avatar } from '../components/ui'; +import { Button, Card, CardContent, CardHeader, Avatar } from '../components/ui'; +import { useAuth } from '../contexts/AuthContext'; import { Dropdown, DatePickerTrigger, CommentEditor } from '../components/work-item'; +import { DescriptionEditor } from '../components/work-item/DescriptionEditor'; +import { IssueActivityFeed } from '../components/work-item/IssueActivityFeed'; +import { CommentReactions } from '../components/work-item/CommentReactions'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; import { issueService } from '../services/issueService'; @@ -12,6 +16,14 @@ import { moduleService } from '../services/moduleService'; import { recentsService } from '../services/recentsService'; import { commentService } from '../services/commentService'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; +import { IssuePRSidebar } from '../components/work-item/IssuePRSidebar'; +import { + PriorityIcon, + StatePill, + WorkItemAvatarGroup, +} from '../components/work-item/IssueRowCells'; +import { membersFromAssigneeIds } from '../lib/issueRowHelpers'; +import { getImageUrl } from '../lib/utils'; import type { WorkspaceApiResponse, ProjectApiResponse, @@ -20,18 +32,41 @@ import type { LabelApiResponse, WorkspaceMemberApiResponse, IssueCommentApiResponse, + IssueActivityApiResponse, CycleApiResponse, ModuleApiResponse, } from '../api/types'; import type { Priority } from '../types'; -const priorityVariant: Record = { - urgent: 'danger', - high: 'danger', - medium: 'warning', - low: 'default', - none: 'neutral', -}; +/** + * Shared trigger style for the Properties sidebar dropdowns. Borderless + + * background-on-hover so each row reads like a "value" rather than a button — + * matches Plane's transparent-with-text variant. Content-width so the row's + * `justify-end` can right-align it. + */ +const GHOST_TRIGGER = + 'inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-(--radius-md) px-2 py-1.5 text-xs text-(--txt-secondary) hover:bg-(--bg-layer-1-hover)'; + +/** One row in the Properties sidebar: fixed-width label on the left, value right-aligned. */ +function PropertyRow({ + icon, + label, + children, +}: { + icon: React.ReactNode; + label: string; + children: React.ReactNode; +}) { + return ( +
    +
    + {icon} + {label} +
    +
    {children}
    +
    + ); +} const IconPlus = () => ( ( ); +/** Tiny lock icon used to mark internal comments. */ +const CommentLockIcon = () => ( + + + + +); +/** GitHub mark — used as the avatar for bot-posted comments. */ +const BotGitHubIcon = () => ( + + + +); export function IssueDetailPage() { + const { user: currentUser } = useAuth(); const { workspaceSlug, projectId, issueId } = useParams<{ workspaceSlug: string; projectId: string; @@ -149,6 +206,7 @@ export function IssueDetailPage() { const [members, setMembers] = useState([]); const [allIssues, setAllIssues] = useState([]); const [comments, setComments] = useState([]); + const [activities, setActivities] = useState([]); const [loading, setLoading] = useState(true); const [openDropdown, setOpenDropdown] = useState(null); const [subCreateOpen, setSubCreateOpen] = useState(false); @@ -177,8 +235,11 @@ export function IssueDetailPage() { workspaceService.listMembers(workspaceSlug), issueService.list(workspaceSlug, projectId, { limit: 250 }), commentService.list(workspaceSlug, projectId, issueId), + issueService + .listActivities(workspaceSlug, projectId, issueId) + .catch(() => [] as IssueActivityApiResponse[]), ]) - .then(([w, p, i, st, lab, cy, mod, mem, all, com]) => { + .then(([w, p, i, st, lab, cy, mod, mem, all, com, acts]) => { if (!cancelled) { setWorkspace(w ?? null); setProject(p ?? null); @@ -190,6 +251,7 @@ export function IssueDetailPage() { setMembers(mem ?? []); setAllIssues(all ?? []); setComments(com ?? []); + setActivities(acts ?? []); if (workspaceSlug && i) { recentsService .record(workspaceSlug, { @@ -213,6 +275,7 @@ export function IssueDetailPage() { setMembers([]); setAllIssues([]); setComments([]); + setActivities([]); } }) .finally(() => { @@ -223,8 +286,6 @@ export function IssueDetailPage() { }; }, [workspaceSlug, projectId, issueId]); - const getStateName = (stateId: string | null | undefined) => - stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : '—'; const getMemberLabel = (memberId: string | null | undefined) => { if (!memberId) return '—'; const m = members.find((x) => x.member_id === memberId); @@ -234,6 +295,16 @@ export function IssueDetailPage() { if (emailUser) return emailUser; return 'Member'; }; + const getMemberAvatar = (memberId: string | null | undefined): string | null => { + if (!memberId) return null; + const m = members.find((x) => x.member_id === memberId); + const raw = m?.member_avatar?.trim(); + return raw ? raw : null; + }; + const mentionMembers = members.map((m) => ({ + id: m.member_id, + label: m.member_display_name?.trim() || m.member_email?.split('@')[0]?.trim() || 'Member', + })); const assigneeIds = issue?.assignee_ids ?? []; const labelIds = issue?.label_ids ?? []; @@ -270,8 +341,18 @@ export function IssueDetailPage() { }; const selectedCycle = cycleIds.length ? (cycles.find((c) => c.id === cycleIds[0]) ?? null) : null; - const selectedModule = moduleIds.length - ? (modules.find((m) => m.id === moduleIds[0]) ?? null) + const selectedModules = moduleIds + .map((id) => modules.find((m) => m.id === id)) + .filter((m): m is ModuleApiResponse => Boolean(m)); + const currentState = issue.state_id + ? (states.find((s) => s.id === issue.state_id) ?? null) + : null; + const issueAssignees = membersFromAssigneeIds(members, assigneeIds); + const selectedLabels = labelIds + .map((id) => labels.find((l) => l.id === id)) + .filter((l): l is LabelApiResponse => Boolean(l)); + const createdByMember = issue.created_by_id + ? members.find((m) => m.member_id === issue.created_by_id) : null; const children = allIssues.filter((i) => i.parent_id === issue.id); @@ -338,7 +419,7 @@ export function IssueDetailPage() { return 'just now'; }; - const postComment = async (contentHtml: string) => { + const postComment = async (contentHtml: string, access: 'INTERNAL' | 'EXTERNAL' = 'INTERNAL') => { if (!workspaceSlug || !contentHtml.trim()) return; setErrorMessage(null); setPostingComment(true); @@ -348,6 +429,7 @@ export function IssueDetailPage() { project.id, issue.id, contentHtml.trim(), + access, ); setComments((prev) => [...prev, created]); } catch (err) { @@ -434,14 +516,12 @@ export function IssueDetailPage() { Description - {descriptionHtml ? ( -
    - ) : ( -

    Click to add description.

    - )} + updateIssue({ description: html, description_html: html })} + placeholder="Add a description… (type / for commands)" + mentionMembers={mentionMembers} + /> @@ -451,88 +531,131 @@ export function IssueDetailPage() { Comments {comments.length} -
    -
    - -
    -

    - - {getMemberLabel(issue.created_by_id)} - {' '} - created this work item. - - {new Date(issue.created_at).toLocaleDateString()} - -

    -
    -
    +
    + {/* Real activity feed driven by IssueActivity rows. */} + {comments.length === 0 ? (

    No comments yet.

    ) : ( comments.map((c) => { const isEditing = editingCommentId === c.id; + const isBot = !c.created_by_id; + const isOwn = + !!c.created_by_id && !!currentUser?.id && c.created_by_id === currentUser.id; + const authorName = isBot ? 'GitHub' : getMemberLabel(c.created_by_id); + const editedAt = c.updated_at && c.updated_at !== c.created_at; return ( -
    -
    +
    + {isBot ? ( + + + + ) : ( -
    -
    - - {getMemberLabel(c.created_by_id)} + )} +
    +
    +
    + + {authorName} -
    - {formatRelativeTime(c.created_at)} - - -
    + + + )}
    - {isEditing ? ( -
    - void updateComment(c.id, html)} - isSubmitting={updatingCommentId === c.id} - onCancel={() => setEditingCommentId(null)} - showShortcutHint - autoFocus - /> -
    - ) : ( +
    + + {formatRelativeTime(c.created_at)} + {editedAt && ' (edited)'} + + {isOwn && !isEditing && ( + <> + + + + )} +
    +
    + {isEditing ? ( +
    + void updateComment(c.id, html)} + isSubmitting={updatingCommentId === c.id} + onCancel={() => setEditingCommentId(null)} + showShortcutHint + autoFocus + mentionMembers={mentionMembers} + /> +
    + ) : ( + <>
    - )} -
    + {workspaceSlug && ( + + )} + + )}
    ); @@ -543,6 +666,8 @@ export function IssueDetailPage() { onSubmit={postComment} isSubmitting={postingComment} showShortcutHint + showAccessToggle + mentionMembers={mentionMembers} /> @@ -571,56 +696,87 @@ export function IssueDetailPage() {
    + {workspaceSlug && ( + + )} Properties - -
    - State + + {/* State */} + } label="State"> } - displayValue={getStateName(issue.state_id ?? undefined)} - compact + displayValue="" align="right" + triggerClassName={GHOST_TRIGGER} + triggerContent={ + currentState ? ( + + ) : ( + Select state + ) + } > {states.map((s) => ( ))} -
    + -
    - Assignees + {/* Assignees */} + } label="Assignees"> } - displayValue={ - assigneeIds.length ? `${assigneeIds.length} selected` : 'Unassigned' - } - compact + displayValue="" align="right" - panelClassName="max-h-72 min-w-[220px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerClassName={GHOST_TRIGGER} + panelClassName="max-h-72 min-w-[240px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerContent={ + issueAssignees.length === 0 ? ( + Add assignee + ) : ( +
    + + + {issueAssignees.length === 1 + ? issueAssignees[0].name + : `${issueAssignees.length} assignees`} + +
    + ) + } > {members.length === 0 ? (
    No members.
    @@ -642,6 +798,12 @@ export function IssueDetailPage() { {checked ? '✓' : ''} + {getMemberLabel(m.member_id)} @@ -650,46 +812,65 @@ export function IssueDetailPage() { }) )}
    -
    + -
    - Priority + {/* Priority */} + } label="Priority"> } - displayValue={issue.priority ?? 'none'} - compact + displayValue="" align="right" + triggerClassName={GHOST_TRIGGER} + triggerContent={ +
    + + + {issue.priority ?? 'No priority'} + +
    + } > {(['urgent', 'high', 'medium', 'low', 'none'] as Priority[]).map((p) => ( ))}
    -
    + -
    - Created by - {getMemberLabel(issue.created_by_id)} -
    + {/* Created by (read-only) */} + } label="Created by"> +
    + + + {getMemberLabel(issue.created_by_id)} + +
    +
    -
    - Start date + {/* Start date */} + } label="Start date"> } @@ -697,9 +878,10 @@ export function IssueDetailPage() { placeholder="Add start date" onChange={(v) => updateIssue({ start_date: v })} /> -
    -
    - Due date + + + {/* Due date */} + } label="Due date"> } @@ -707,19 +889,40 @@ export function IssueDetailPage() { placeholder="Add due date" onChange={(v) => updateIssue({ target_date: v })} /> -
    + -
    - Modules + {/* Modules (multi) */} + } label="Modules"> } - displayValue={selectedModule?.name ?? 'No module'} - compact + displayValue="" align="right" + triggerClassName={GHOST_TRIGGER} + triggerContent={ + selectedModules.length === 0 ? ( + No modules + ) : ( +
    + {selectedModules.slice(0, 2).map((m) => ( + + {m.name} + + ))} + {selectedModules.length > 2 && ( + + +{selectedModules.length - 2} + + )} +
    + ) + } > {modules.map((m) => ( ))}
    -
    + -
    - Cycle + {/* Cycle */} + } label="Cycle"> } - displayValue={selectedCycle?.name ?? 'No cycle'} - compact + displayValue="" align="right" + triggerClassName={GHOST_TRIGGER} + triggerContent={ + selectedCycle ? ( + + {selectedCycle.name} + + ) : ( + No cycle + ) + } > {cycles.map((c) => ( ))} -
    + -
    - Parent + {/* Parent */} + } label="Parent"> } - displayValue={parentIssue ? parentIssue.name : 'Add parent work item'} - compact + displayValue="" align="right" - panelClassName="max-h-72 min-w-[260px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerClassName={GHOST_TRIGGER} + panelClassName="max-h-72 min-w-[280px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerContent={ + parentIssue ? ( +
    + + {project.identifier ?? project.id.slice(0, 8)}- + {parentIssue.sequence_id ?? parentIssue.id.slice(-4)} + + {parentIssue.name} +
    + ) : ( + Add parent work item + ) + } > + {parentIssue && ( + + )} {filteredParentOptions.slice(0, 200).map((pi) => ( ))}
    -
    + -
    - Labels + {/* Labels (multi-chip) */} + } label="Labels"> } - displayValue={labelIds.length ? `${labelIds.length} selected` : 'Select label'} - compact + displayValue="" align="right" - panelClassName="max-h-72 min-w-[220px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerClassName={GHOST_TRIGGER} + panelClassName="max-h-72 min-w-[240px] overflow-auto rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerContent={ + selectedLabels.length === 0 ? ( + Select label + ) : ( +
    + {selectedLabels.slice(0, 3).map((l) => ( + + + {l.name} + + ))} + {selectedLabels.length > 3 && ( + + +{selectedLabels.length - 3} + + )} +
    + ) + } > {labels.length === 0 ? (
    No labels.
    @@ -853,13 +1121,18 @@ export function IssueDetailPage() { {checked ? '✓' : ''} + {l.name} ); }) )}
    -
    +
    diff --git a/ui/src/pages/IssueListPage.tsx b/ui/src/pages/IssueListPage.tsx index e43d8a66..5852a847 100644 --- a/ui/src/pages/IssueListPage.tsx +++ b/ui/src/pages/IssueListPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; -import { Link, useParams, useSearchParams } from 'react-router-dom'; -import { Badge, Avatar, Button } from '../components/ui'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { Button } from '../components/ui'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; @@ -9,6 +9,13 @@ import { stateService } from '../services/stateService'; import { labelService } from '../services/labelService'; import { cycleService } from '../services/cycleService'; import { moduleService } from '../services/moduleService'; +import { integrationService } from '../services/integrationService'; +import { IssueLayoutList } from '../components/work-item/layouts/IssueLayoutList'; +import { IssueLayoutBoard } from '../components/work-item/layouts/IssueLayoutBoard'; +import { IssueLayoutSpreadsheet } from '../components/work-item/layouts/IssueLayoutSpreadsheet'; +import { IssueLayoutCalendar } from '../components/work-item/layouts/IssueLayoutCalendar'; +import { IssueLayoutGantt } from '../components/work-item/layouts/IssueLayoutGantt'; +import { parseIssueLayout } from '../components/work-item/layouts/IssueLayoutTypes'; import type { WorkspaceApiResponse, ProjectApiResponse, @@ -18,6 +25,7 @@ import type { WorkspaceMemberApiResponse, CycleApiResponse, ModuleApiResponse, + GitHubIssueSummaryEntry, } from '../api/types'; import type { Priority } from '../types'; import type { StateGroup } from '../types/workspaceViewFilters'; @@ -35,15 +43,7 @@ import { type ProjectIssuesDisplayPayload, type ProjectIssuesFiltersState, } from '../lib/projectIssuesEvents'; -import { findWorkspaceMemberByUserId, getImageUrl, normalizeUuidKey } from '../lib/utils'; - -const priorityVariant: Record = { - urgent: 'danger', - high: 'danger', - medium: 'warning', - low: 'default', - none: 'neutral', -}; +import { normalizeUuidKey } from '../lib/utils'; function issueMentionSearchBlob(issue: IssueApiResponse): string { const parts: string[] = []; @@ -69,70 +69,6 @@ function issueMentionsUserId(issue: IssueApiResponse, userId: string): boolean { return blob.includes(u); } -const IconCalendar = () => ( - - - - - - -); -const IconUser = () => ( - - - - -); -const IconTag = () => ( - - - -); -const IconEye = () => ( - - - - -); -const IconMoreVertical = () => ( - - - - - -); const IconPlus = () => ( ( ); -const IconLinkOut = () => ( - - - - - -); - -function formatShortDate(iso: string | null | undefined): string | null { - if (!iso?.trim()) return null; - const t = Date.parse(iso); - if (Number.isNaN(t)) return null; - return new Date(t).toLocaleDateString(); -} - export function IssueListPage() { const { workspaceSlug, projectId } = useParams<{ workspaceSlug: string; @@ -187,6 +100,7 @@ export function IssueListPage() { const [cycles, setCycles] = useState([]); const [modules, setModules] = useState([]); const [members, setMembers] = useState([]); + const [prSummary, setPrSummary] = useState>({}); const [loading, setLoading] = useState(true); const [createError, setCreateError] = useState(null); const [listFilters, setListFilters] = useState(() => ({ @@ -254,6 +168,35 @@ export function IssueListPage() { }; }, [workspaceSlug, projectId]); + // Bulk-fetch GitHub PR summaries for the loaded issues. Re-runs when the + // set of issue IDs changes (stable join key). The service short-circuits to + // {} for an empty list, and a 404 (no integration / project not linked) + // also collapses to "no badges" silently. + const issueIDsKey = useMemo( + () => + issues + .map((i) => i.id) + .sort() + .join(','), + [issues], + ); + useEffect(() => { + if (!workspaceSlug || !projectId) return; + let cancelled = false; + const ids = issueIDsKey ? issueIDsKey.split(',') : []; + integrationService + .githubIssueSummary(workspaceSlug, projectId, ids) + .then((map) => { + if (!cancelled) setPrSummary(map); + }) + .catch(() => { + if (!cancelled) setPrSummary({}); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId, issueIDsKey]); + useLayoutEffect(() => { const handler = (e: Event) => { const ce = e as CustomEvent<{ @@ -465,22 +408,10 @@ export function IssueListPage() { ], ); - const getStateName = (stateId: string | null | undefined) => - stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : '—'; - const getLabelNames = (labelIds: string[] = []) => - labelIds - .map((id) => labels.find((l) => l.id === id)?.name) - .filter((name): name is string => Boolean(name)); - const getUser = (userId: string | null) => { - if (!userId) return null; - const m = findWorkspaceMemberByUserId(members, userId); - const display = m?.member_display_name?.trim() ?? ''; - const emailUser = m?.member_email?.trim().split('@')[0]?.trim() ?? ''; - const name = display !== '' ? display : emailUser !== '' ? emailUser : userId.slice(0, 8); - const raw = m?.member_avatar?.trim(); - const avatarUrl = raw ? raw : null; - return { id: userId, name, avatarUrl }; - }; + // Stable "now" timestamp used by overdue/relative-date cells. Sampled once + // at mount via useState's lazy initializer (allowed to be impure) so each + // row stays pure for the rest of the render-tree's lifetime. + const [now] = useState(() => Date.now()); const createParam = searchParams.get('create') === '1'; @@ -568,166 +499,21 @@ export function IssueListPage() { return id ? (modules.find((m) => m.id === id)?.name ?? '—') : '—'; }; - const renderIssueRow = (issue: IssueApiResponse) => { - const primaryAssigneeId = - issue.assignee_ids && issue.assignee_ids.length > 0 ? issue.assignee_ids[0] : null; - const assignee = getUser(primaryAssigneeId); - const labelNames = getLabelNames(issue.label_ids ?? []); - const displayId = `${project.identifier ?? project.id.slice(0, 8)}-${issue.sequence_id ?? issue.id.slice(-4)}`; - const startStr = formatShortDate(issue.start_date); - const dueStr = formatShortDate(issue.target_date); - const subN = subWorkCountByParentId.get(issue.id) ?? 0; - const issueUrl = `${baseUrl}/issues/${issue.id}`; - - return ( -
  • - - - {hasCol('id') ? ( - <> - {displayId} - {issue.name} - - ) : ( - {issue.name} - )} - -
    - {hasCol('state') ? ( - - - {getStateName(issue.state_id ?? undefined)} - - - ) : null} - {hasCol('priority') ? ( - - - {issue.priority ?? '—'} - - - ) : null} - {hasCol('start_date') ? ( - - {startStr ?? '—'} - - ) : null} - {hasCol('due_date') ? ( - - - - ) : null} - {hasCol('assignee') ? ( - - {assignee ? ( - - ) : ( - - )} - - ) : null} - {hasCol('labels') ? ( - - {labelNames.length > 0 ? ( - - ) : ( - - - - )} - - ) : null} - {hasCol('sub_work_count') ? ( - - {subN} - - ) : null} - {hasCol('attachment_count') ? ( - - — - - ) : null} - {hasCol('estimate') ? ( - - ) : null} - {hasCol('module') ? ( - - {moduleName(issue)} - - ) : null} - {hasCol('cycle') ? ( - - {cycleName(issue)} - - ) : null} - {hasCol('link') ? ( - e.stopPropagation()} - > - - - ) : null} - - - - -
    - -
  • - ); + const layout = parseIssueLayout(searchParams.get('layout')); + const issueHref = (id: string) => `${baseUrl}/issues/${id}`; + const layoutProps = { + workspaceSlug: workspace.slug, + project, + issues: groupedIssues.isFlat + ? (groupedIssues.groups.get(groupedIssues.order[0]) ?? []) + : filteredIssues, + states, + labels, + members, + prSummary, + baseUrl, + issueHref, + now, }; return ( @@ -766,44 +552,33 @@ export function IssueListPage() {
    ) : ( <> - {groupedIssues.isFlat ? ( -
      - {(groupedIssues.groups.get(groupedIssues.order[0]) ?? []).map((issue) => - renderIssueRow(issue), - )} -
    - ) : ( -
    - {groupedIssues.order.map((sectionKey) => { - const sectionIssues = groupedIssues.groups.get(sectionKey) ?? []; - if (sectionIssues.length === 0 && !listDisplay.showEmptyGroups) return null; - const title = groupedIssues.title(sectionKey); - return ( -
    -

    - {title} - - {sectionIssues.length} - -

    -
      - {sectionIssues.map((issue) => renderIssueRow(issue))} -
    -
    - ); - })} + {layout === 'list' && ( + + )} + {layout === 'board' && } + {layout === 'spreadsheet' && } + {layout === 'calendar' && } + {layout === 'gantt' && } + {layout === 'list' && ( +
    +
    )} -
    - -
    )} diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index 7427c8f8..2b1b4aaf 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -229,7 +229,7 @@ export function ProjectsListPage() { return (
    {/* Cover image */} diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index a47cbb12..880f35c0 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Card, CardContent, Button, Avatar, Modal } from '../components/ui'; import { CoverImageModal } from '../components/CoverImageModal'; +import { IntegrationsSection } from '../components/integrations/IntegrationsSection'; import { UploadImageModal } from '../components/UploadImageModal'; import { ProjectIconModal, ProjectIconDisplay } from '../components/ProjectIconModal'; import { getImageUrl } from '../lib/utils'; @@ -68,7 +69,7 @@ const IconUsers = () => ( ); -const IconCreditCard = () => ( +const IconPlug = () => ( ( strokeLinejoin="round" aria-hidden > - - + + + + ); const IconUpload = () => ( @@ -637,7 +640,7 @@ const IconZap = () => ( ); -type WorkspaceSettingsSection = 'general' | 'members' | 'billing' | 'exports' | 'webhooks'; +type WorkspaceSettingsSection = 'general' | 'members' | 'integrations' | 'exports' | 'webhooks'; type ProjectSettingsSection = | 'general' | 'members' @@ -692,7 +695,7 @@ const WORKSPACE_SECTIONS: { }[] = [ { id: 'general', label: 'General', icon: }, { id: 'members', label: 'Members', icon: }, - { id: 'billing', label: 'Billing & Plans', icon: }, + { id: 'integrations', label: 'Integrations', icon: }, { id: 'exports', label: 'Exports', icon: }, { id: 'webhooks', label: 'Webhooks', icon: }, ]; @@ -3619,14 +3622,8 @@ export function SettingsPage() {
    )} - {!isAccountTab && !isProjectsTab && section === 'billing' && ( - - -

    - Billing & Plans settings will be available when the API is connected. -

    -
    -
    + {!isAccountTab && !isProjectsTab && section === 'integrations' && workspaceSlug && ( + )} {!isAccountTab && !isProjectsTab && section === 'exports' && ( diff --git a/ui/src/pages/instance-admin/InstanceAdminIntegrationGitHubPage.tsx b/ui/src/pages/instance-admin/InstanceAdminIntegrationGitHubPage.tsx new file mode 100644 index 00000000..5e78afbb --- /dev/null +++ b/ui/src/pages/instance-admin/InstanceAdminIntegrationGitHubPage.tsx @@ -0,0 +1,375 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Eye, EyeOff } from 'lucide-react'; +import { Button, Input } from '../../components/ui'; +import { InstanceAdminCopyRow } from '../../components/instance-admin'; +import { instanceSettingsService } from '../../services/instanceService'; +import { authService } from '../../services/authService'; +import { getApiErrorMessage } from '../../api/client'; +import type { InstanceGitHubAppSection } from '../../api/types'; + +const IconGitHub = () => ( + + + +); + +/** + * Configure the GitHub App credentials for the whole instance. Until this is + * filled in, no workspace can connect GitHub. Secrets (client secret, private + * key, webhook secret) are encrypted at rest and never echoed back from the + * API — the form clears the field after save and shows a *_set badge instead. + */ +export function InstanceAdminIntegrationGitHubPage() { + const navigate = useNavigate(); + + // Form state. Secrets default to empty; if the corresponding *_set is true, + // the placeholder tells the user "(unchanged if blank)". + const [appID, setAppID] = useState(''); + const [appName, setAppName] = useState(''); + const [clientID, setClientID] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [clientSecretSet, setClientSecretSet] = useState(false); + const [privateKey, setPrivateKey] = useState(''); + const [privateKeySet, setPrivateKeySet] = useState(false); + const [webhookSecret, setWebhookSecret] = useState(''); + const [webhookSecretSet, setWebhookSecretSet] = useState(false); + + // For the snapshot we compare against to compute isDirty. + const [initial, setInitial] = useState({ + appID: '', + appName: '', + clientID: '', + }); + + const [showClientSecret, setShowClientSecret] = useState(false); + const [showPrivateKey, setShowPrivateKey] = useState(false); + const [showWebhookSecret, setShowWebhookSecret] = useState(false); + + // URLs we need to display (admin pastes into the App's settings on github.com). + const [oauthRedirectBase, setOauthRedirectBase] = useState(''); + const [oauthJsOrigin, setOauthJsOrigin] = useState(''); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const callbackUrl = useMemo( + () => (oauthRedirectBase ? `${oauthRedirectBase}/auth/github-app/callback` : ''), + [oauthRedirectBase], + ); + const webhookUrl = useMemo( + () => (oauthRedirectBase ? `${oauthRedirectBase}/webhooks/github` : ''), + [oauthRedirectBase], + ); + + useEffect(() => { + let cancelled = false; + Promise.all([instanceSettingsService.getSettings(), authService.getAuthConfig()]) + .then(([settings, cfg]) => { + if (cancelled) return; + const g = (settings.github_app || {}) as InstanceGitHubAppSection; + setAppID(g.app_id ?? ''); + setAppName(g.app_name ?? ''); + setClientID(g.client_id ?? ''); + setClientSecretSet(g.client_secret_set ?? false); + setPrivateKeySet(g.private_key_set ?? false); + setWebhookSecretSet(g.webhook_secret_set ?? false); + setInitial({ + appID: g.app_id ?? '', + appName: g.app_name ?? '', + clientID: g.client_id ?? '', + }); + if (cfg.oauth_redirect_base) setOauthRedirectBase(cfg.oauth_redirect_base); + if (cfg.oauth_js_origin) { + setOauthJsOrigin(cfg.oauth_js_origin); + } else if (typeof window !== 'undefined') { + setOauthJsOrigin(window.location.origin); + } + }) + .catch((err) => { + if (!cancelled) setError(getApiErrorMessage(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const isDirty = + appID !== initial.appID || + appName !== initial.appName || + clientID !== initial.clientID || + clientSecret.length > 0 || + privateKey.length > 0 || + webhookSecret.length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + setSaving(true); + + const payload: InstanceGitHubAppSection = { + app_id: appID.trim(), + app_name: appName.trim(), + client_id: clientID.trim(), + }; + if (clientSecret.trim()) payload.client_secret = clientSecret.trim(); + if (privateKey.trim()) payload.private_key = privateKey; + if (webhookSecret.trim()) payload.webhook_secret = webhookSecret.trim(); + + instanceSettingsService + .updateSection('github_app', payload as import('../../api/types').InstanceSettingSectionValue) + .then((res) => { + const v = (res.value || {}) as InstanceGitHubAppSection; + setAppID(v.app_id ?? ''); + setAppName(v.app_name ?? ''); + setClientID(v.client_id ?? ''); + setClientSecretSet(v.client_secret_set ?? false); + setPrivateKeySet(v.private_key_set ?? false); + setWebhookSecretSet(v.webhook_secret_set ?? false); + setInitial({ + appID: v.app_id ?? '', + appName: v.app_name ?? '', + clientID: v.client_id ?? '', + }); + // Clear local secret fields — they've been saved. + setClientSecret(''); + setPrivateKey(''); + setWebhookSecret(''); + setSuccess('GitHub App settings saved. Workspaces can now connect.'); + }) + .catch((err) => setError(getApiErrorMessage(err))) + .finally(() => setSaving(false)); + }; + + if (loading) { + return ( +
    +
    +
    +
    +
    +
    +
    + ); + } + + return ( +
    +
    + + + +
    +

    GitHub App

    +

    + Register a GitHub App and paste its credentials here. The App is the bridge that lets + Devlane sync pull requests with issues across all workspaces on this instance. +

    +
    +
    + + {error &&

    {error}

    } + {success &&

    {success}

    } + +
    +

    First time? Quick setup:

    +
      +
    1. + Open{' '} + + GitHub Settings → Developer settings → GitHub Apps → New GitHub App + + . +
    2. +
    3. Set the URLs and webhook from the boxes below.
    4. +
    5. + Permissions: Contents: Read,{' '} + Issues: R/W,{' '} + Pull requests: R/W,{' '} + Metadata: Read. +
    6. +
    7. + Subscribe to events: Pull request,{' '} + Push,{' '} + Issue comment,{' '} + Installation,{' '} + Installation repositories. +
    8. +
    9. + After creating, copy the App ID, App slug, Client ID, and generate a Client Secret + + Private key (.pem) + Webhook secret. Paste them here. +
    10. +
    +
    + +
    +
    +

    + Credentials from your GitHub App +

    +
    + setAppID(e.target.value)} + autoComplete="off" + placeholder="e.g. 1234567" + inputMode="numeric" + /> +

    + The numeric App ID shown at the top of your GitHub App settings page. +

    + + setAppName(e.target.value)} + autoComplete="off" + placeholder="e.g. devlane-acme" + /> +

    + The URL-safe slug from your App's public page,{' '} + github.com/apps/<slug>. Used to build the + install link. +

    + + setClientID(e.target.value)} + autoComplete="off" + placeholder="e.g. Iv1.abc123def456" + /> + +
    + setClientSecret(e.target.value)} + autoComplete="new-password" + placeholder={clientSecretSet ? '(unchanged if left blank)' : 'Enter client secret'} + /> + +
    +

    + Generate a fresh client secret in your App's settings under "Client secrets". +

    + +
    + setWebhookSecret(e.target.value)} + autoComplete="new-password" + placeholder={ + webhookSecretSet ? '(unchanged if left blank)' : 'Enter webhook secret' + } + /> + +
    +

    + Use the same secret you set in the App's "Webhook" → "Webhook secret" field. Devlane + uses it to verify each delivery's HMAC signature. +

    + +