diff --git a/.claude/skills/develop/SKILL.md b/.claude/skills/develop/SKILL.md new file mode 100644 index 0000000..da8a9f6 --- /dev/null +++ b/.claude/skills/develop/SKILL.md @@ -0,0 +1,368 @@ +--- +name: develop +description: > + Guided development workflow for building, fixing, updating, or refactoring + the committee service — new endpoints, business logic, data models, NATS + storage, or full features end-to-end. Use whenever someone wants to add a + feature, fix a bug, modify existing behavior, or implement any code change + in this Go service. +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion +--- + +# Committee Service Development Guide + +You are helping a contributor build within the LFX V2 Committee Service. This is a Go service — explain concepts in plain language and walk through each step. Point to existing code as examples whenever possible. + +**Important:** You are building within the existing architecture, not redesigning it. If something seems to require changes to authentication, infrastructure, or deployment configuration, flag it for a code owner. + +## The Mental Model: How This Service Works + +Before writing any code, help the contributor understand the flow. When someone calls an API endpoint (e.g., "get committee ABC"), here's what happens: + +``` +HTTP Request + ↓ +HTTP Handler (cmd/committee-api/service/) + — thin layer, validates input, calls internal service + ↓ +Internal Service (internal/service/) + — business logic: validation, rules, orchestration + ↓ +Port Interface (internal/domain/port/) + — an "abstract contract" that defines what storage operations exist + ↓ +NATS Implementation (internal/infrastructure/nats/) + — actually reads/writes to the NATS key-value buckets + ↓ +NATS (the database) +``` + +**The golden rule:** Build from the bottom up. Data models and interfaces first, then storage, then business logic, then the HTTP layer. This prevents building an API that doesn't have the data to back it up. + +## Step 1: Branch Setup + +Before writing any code: + +```bash +git checkout main +git pull origin main +``` + +Create a feature branch named after the JIRA ticket: +```bash +git checkout -b feat/LFXV2--short-description +``` + +If there's no JIRA ticket yet, ask the contributor to create one in the LFXV2 project first. + +## Step 2: Understand the Feature + +Ask the contributor what they're building. Before writing code, answer: + +1. **What is the feature?** Describe it in one sentence from the user's perspective. +2. **What data does it need?** What fields/information are involved? +3. **What operation is this?** Create, Read, Update, Delete, or a query? +4. **Does the API endpoint already exist?** Check `cmd/committee-api/design/committee_svc.go` and the generated `gen/` folder. +5. **Does similar code already exist?** Check for existing patterns to follow. + +## Step 3: Explore Existing Code First + +Always read what exists before writing anything new: + +```bash +# See what endpoints already exist +ls gen/http/committee/ + +# Read the design specification (source of truth for the API contract) +# cmd/committee-api/design/committee_svc.go + +# See existing service methods +ls cmd/committee-api/service/ + +# See internal business logic +ls internal/service/ + +# See data models +ls internal/domain/model/ + +# See port interfaces (what storage operations are available) +ls internal/domain/port/ + +# See NATS implementations +ls internal/infrastructure/nats/ +``` + +Read at least one existing example in the same area as the work being done before generating new code. + +## Step 4: Plan Before Coding + +Based on the feature, determine which layers need to change: + +| Layer | File Location | When to Change | +|-------|--------------|----------------| +| **API Design** | `cmd/committee-api/design/committee_svc.go` | New endpoint or new request/response fields | +| **Data Types** | `cmd/committee-api/design/type.go` or `_type.go` | New Goa types for request/response | +| **Helm Ruleset** | `charts/lfx-v2-committee-service/templates/ruleset.yaml` | Every new endpoint needs an auth rule | +| **Domain Model** | `internal/domain/model/` | New or changed data shape stored in NATS | +| **Port Interface** | `internal/domain/port/` | New storage operation needed | +| **NATS Storage** | `internal/infrastructure/nats/` | Implementing a new port operation | +| **Internal Service** | `internal/service/` | New business logic | +| **HTTP Handler** | `cmd/committee-api/service/` | Wiring up a new endpoint | + +**Build order is strict:** Domain model → Port interface → NATS implementation → Internal service → API design → Code generation → HTTP handler + +Never write the HTTP handler before the storage layer exists — there's nothing for it to call. + +## Step 5: Working with the Goa Design Files + +This service uses **Goa**, a framework that generates HTTP server code from a design specification. Think of it like a blueprint: you describe what your API should look like, and Goa writes the boilerplate HTTP code. + +### When to modify design files + +Only modify design files if you're adding a **new endpoint** or changing the **request/response structure** of an existing one. For pure business logic changes, skip directly to Step 7. + +### The design files + +- `cmd/committee-api/design/committee_svc.go` — Endpoint definitions (HTTP methods, paths, payloads, responses) +- `cmd/committee-api/design/type.go` — Data types used in those endpoints + +### After modifying design files + +Always regenerate: +```bash +make apigen +``` + +This regenerates everything in `gen/` — never edit files in `gen/` directly, they will be overwritten. + +Read `references/goa-patterns.md` for examples of adding endpoints and types. + +## Step 5b: Add a Heimdall Ruleset Entry + +**Every new endpoint must have a corresponding rule in `charts/lfx-v2-committee-service/templates/ruleset.yaml`.** Without it, the request will be blocked at the gateway when deployed. + +Each rule specifies the HTTP method + path and what authorization check to perform. The check uses OpenFGA, which enforces access based on the user's relation to an object. The authorization model for committees is defined in [`lfx-v2-helm`](https://github.com/linuxfoundation/lfx-v2-helm/blob/main/charts/lfx-platform/templates/openfga/model.yaml). To see the live model in your local cluster: + +```bash +kubectl describe authorizationmodel lfx-core -n lfx +``` + +At the time of writing, the `committee` type defines these relations: + +```text +type committee + relations + define member: [user] # explicitly added as a committee member + define writer: [user] or writer from project # can modify the committee + define auditor: [user, team#member] or auditor from project # can view settings/sensitive data + define viewer: [user:*] or member or auditor # can view general committee data (public = anyone) +``` + +**Choose the relation based on what the endpoint does:** + +| Endpoint type | Relation | Example | +| ------------- | -------- | ------- | +| Read public committee data | `viewer` | GET /committees/:uid | +| Read sensitive settings | `auditor` | GET /committees/:uid/settings | +| Create, update, delete | `writer` | PUT /committees/:uid | +| Self-action (user acts on themselves, no prior relation) | none — use `allow_all` | join, leave, accept invite | + +For a standard protected endpoint, the rule looks like this: + +```yaml +- id: "rule:lfx:lfx-v2-committee-service::" + allow_encoded_slashes: 'off' + match: + methods: + - GET + routes: + - path: /committees/:uid/your-resource + execute: + - authenticator: oidc + - authenticator: anonymous_authenticator + {{- if .Values.app.use_oidc_contextualizer }} + - contextualizer: oidc_contextualizer + {{- end }} + {{- if .Values.openfga.enabled }} + - authorizer: openfga_check + config: + values: + relation: viewer # change to writer/auditor as needed + object: "committee:{{ "{{- .Request.URL.Captures.uid -}}" }}" + {{- else }} + - authorizer: allow_all + {{- end }} + - finalizer: create_jwt + config: + values: + aud: {{ .Values.app.audience }} +``` + +For self-action endpoints (where the user has no prior OpenFGA relation to the object), skip the `openfga_check` and use `allow_all` — the service layer enforces business rules: + +```yaml + {{- if .Values.openfga.enabled }} + - authorizer: allow_all # no prior relation to check + {{- else }} + - authorizer: allow_all + {{- end }} +``` + +## Step 6: Data Models and Storage + +### Domain models (what gets stored) + +Data models live in `internal/domain/model/`. Each file represents one concept (e.g., `committee.go`, `committee_member.go`). + +**Conventions:** +- Use Go structs with json tags +- Include license header on all new files +- Keep models focused — one concept per file + +Example structure: +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package model + +type Committee struct { + UID string `json:"uid"` + Name string `json:"name"` + ProjectID string `json:"project_id"` + // ... +} +``` + +### Port interfaces (the storage contract) + +Interfaces in `internal/domain/port/` define what operations are available for each type of data. The service layer calls these interfaces — it doesn't know or care about NATS specifically. + +When adding a new operation, add it to the appropriate interface. Read the existing interfaces to understand the pattern. + +### NATS implementations + +`internal/infrastructure/nats/` contains the actual NATS code. Each file implements the port interface for a data type. When you add a new operation to a port interface, you must implement it here. + +Data is stored as JSON in NATS key-value buckets. Each entry has a key (usually a UID) and a JSON-encoded value. + +Read `references/nats-patterns.md` for NATS storage patterns. + +## Step 7: Business Logic (Internal Service) + +`internal/service/` contains the business rules. This is where most of the interesting work happens. + +**Key patterns:** +- Services receive port interfaces via constructor injection (not direct NATS calls) +- Return domain errors from `pkg/errors/` rather than raw errors +- Use structured logging from `pkg/log/` for important operations +- Validate inputs before calling storage + +Read an existing service file in the same area before writing new business logic. + +## Step 8: HTTP Handler (Last Step) + +After all the lower layers are in place, wire up the HTTP handler in `cmd/committee-api/service/`. + +Handlers in this service are thin — they: +1. Extract data from the Goa-generated request type +2. Call the internal service +3. Map the result to the Goa-generated response type +4. Return errors using the `pkg/errors/` package + +No business logic should live in handlers. If you find yourself writing complex logic here, it belongs in the internal service layer. + +## Step 8b: V1 Compatibility — Does This Change Need to Sync? + +This service coexists with a V1 system ([`project-management`](https://github.com/linuxfoundation/project-management)) that exposes the same committee data via its own API ([V1 committee API docs](https://api-gw.platform.linuxfoundation.org/project-service/v1/api-docs#tag/committeeV2)). The two systems are kept in sync by the `lfx-v1-sync-helper` service. + +**Ask yourself: does this change touch a field that also exists (or should exist) in V1?** + +If yes, you need changes in **three repos**, not just this one: + +| Repo | What to update | +| ---- | -------------- | +| `lfx-v2-committee-service` (this repo) | V2 domain model + API design | +| [`project-management`](https://github.com/linuxfoundation/project-management) | V1 API + data model to add/change the field | +| [`lfx-v1-sync-helper`](https://github.com/linuxfoundation/lfx-v1-sync-helper) | Sync logic that converts between V1 and V2 models | + +### How the sync works + +**V2 → V1 (when data is written in V2):** +Write in V2 → indexed in OpenSearch → triggers `lfx-v1-sync-helper` → writes to V1 (PostgreSQL). The sync helper contains a function that converts the V2 data model to the V1 data model. **Any new V2 field that should appear in V1 must be mapped there.** + +**V1 → V2 (when data is written in V1):** +Write in V1 → triggers `lfx-v1-sync-helper` → indexes in V2 (OpenSearch). The sync helper contains a separate function that maps V1 attributes to V2 attributes. **Any new V1 field that should appear in V2 must be mapped there.** + +### Important: module versioning in lfx-v1-sync-helper + +The `lfx-v1-sync-helper` imports this committee service as a Go module to use the V2 data model types. If you're adding new fields to the V2 model and updating the sync helper in parallel (before the V2 changes are released and tagged), you must point the sync helper's `go.mod` to your branch version rather than the tagged release: + +```bash +# In lfx-v1-sync-helper, point to your in-progress branch +go get github.com/linuxfoundation/lfx-v2-committee-service@ +``` + +Revert this to a tagged version before merging the sync helper PR. + +Read `references/v1-sync-patterns.md` for details on where exactly to update in `lfx-v1-sync-helper`. + +## Step 9: Tests + +Tests live next to the code they test, in `*_test.go` files. + +```bash +# Run all tests +make test + +# Run tests for a specific package +go test -v ./internal/service/... +``` + +Mock implementations for testing live in `internal/infrastructure/mock/`. When writing tests for service layer code, use these mocks rather than a real NATS connection. + +When adding a new operation to a port interface, add it to the mock as well. + +## Step 10: Validate + +Run the full validation suite before finishing: + +```bash +make fmt # Format code +make check # Check formatting, lint, and license headers +make test # Run all tests +make build # Verify the binary builds +``` + +Fix any issues before moving on. + +## Step 11: Commit + +Stage and commit your changes: + +```bash +git add +git commit -s -m "feat(LFXV2-): short description of what you built" +``` + +The `-s` flag adds the required `Signed-off-by` line. The commit message format is `type(ticket): description`. + +Types: `feat` (new feature), `fix` (bug fix), `refactor`, `test`, `docs`, `chore`. + +## Step 12: Summary + +Provide a clear summary of what was built: +- All files created or modified, with their purpose +- Any new endpoints (method + path) +- Any new domain models or port interface changes +- How to test the feature manually (e.g., curl command) + +**Next step:** Run `/preflight` to validate everything before submitting a PR. + +--- + +## Reference Files + +- `references/goa-patterns.md` — How to add endpoints and types to the Goa design +- `references/nats-patterns.md` — NATS key-value storage patterns +- `references/v1-sync-patterns.md` — Where and how to update lfx-v1-sync-helper for V1/V2 data model changes diff --git a/.claude/skills/develop/references/goa-patterns.md b/.claude/skills/develop/references/goa-patterns.md new file mode 100644 index 0000000..98b16d9 --- /dev/null +++ b/.claude/skills/develop/references/goa-patterns.md @@ -0,0 +1,107 @@ +# Goa Design Patterns + +Goa is a code generation framework. You write a **design specification** describing your API, then run `make apigen` to generate the HTTP server, client, and OpenAPI documentation automatically. + +## Files + +- `cmd/committee-api/design/committee_svc.go` — Service and method (endpoint) definitions +- `cmd/committee-api/design/type.go` — Shared/cross-entity types: common attributes (Version, ETag, BearerToken, CreatedAt, UpdatedAt, etc.) and error types +- `cmd/committee-api/design/committee_type.go` — Committee entity types and attribute helpers +- `cmd/committee-api/design/committee_member_type.go` — Committee member entity types and attribute helpers + +**Type file convention:** Keep `type.go` for shared utilities and error types only. When an entity has enough types to warrant its own file, create `_type.go` (e.g. `committee_type.go`, `committee_member_type.go`). Do not put committee-specific types in `type.go`. + +**Never edit files in `gen/`** — they are fully overwritten by `make apigen`. + +## Adding a New Endpoint + +### Step 1: Define the method in committee_svc.go + +Add a new `dsl.Method(...)` block inside the existing `dsl.Service(...)` block: + +```go +dsl.Method("get-committee-stats", func() { + dsl.Description("Get statistics for a committee") + + dsl.Security(JWTAuth) + + dsl.Payload(func() { + BearerTokenAttribute() + VersionAttribute() + CommitteeUIDAttribute() // reuse existing attribute helpers + }) + + dsl.Result(func() { + dsl.Attribute("stats", CommitteeStats) // your new type + dsl.Required("stats") + }) + + dsl.Error("BadRequest", BadRequestError, "Bad request") + dsl.Error("NotFound", NotFoundError, "Resource not found") + dsl.Error("InternalServerError", InternalServerError, "Internal server error") + + dsl.HTTP(func() { + dsl.GET("/committees/{committee_uid}/stats") + dsl.Param("version:v") + dsl.Header("bearer_token:Authorization") + dsl.Response(dsl.StatusOK) + dsl.Response("BadRequest", dsl.StatusBadRequest) + dsl.Response("NotFound", dsl.StatusNotFound) + dsl.Response("InternalServerError", dsl.StatusInternalServerError) + }) +}) +``` + +### Step 2: Define new types (if needed) + +If the endpoint needs new request/response shapes, add types to the appropriate file — `_type.go` for entity-specific types, or `type.go` for shared utilities: + +```go +var CommitteeStats = dsl.Type("committee-stats", func() { + dsl.Description("Statistics for a committee") + dsl.Attribute("member_count", dsl.Int, "Number of active members") + dsl.Attribute("created_at", dsl.String, "ISO 8601 creation timestamp") + dsl.Required("member_count", "created_at") +}) +``` + +### Step 3: Regenerate + +```bash +make apigen +``` + +This generates new files in `gen/http/committee/` including server stubs you must implement. + +### Step 4: Implement the handler + +After generation, Goa will expect a method on the service struct in `cmd/committee-api/service/`. Look for the new method name (camelCased from the design name) and implement it. Follow existing handlers as examples. + +## Reusable Attribute Helpers + +Helper functions like `CommitteeUIDAttribute()` live in `committee_type.go`, shared ones like `BearerTokenAttribute()` and `VersionAttribute()` live in `type.go` — use these instead of defining the same attributes repeatedly. + +To add a new reusable attribute: + +```go +func MemberCountAttribute() { + dsl.Attribute("member_count", dsl.Int, "Number of members in the committee") +} +``` + +## Standard Error Types + +These are already defined — always use them for consistency: + +- `BadRequestError` — invalid input (400) +- `NotFoundError` — resource doesn't exist (404) +- `ConflictError` — uniqueness violation (409) +- `InternalServerError` — unexpected error (500) +- `ServiceUnavailableError` — dependency unavailable (503) + +## Design Constraints + +- Method names use kebab-case: `"get-committee-stats"` not `"getCommitteeStats"` +- HTTP paths use underscores for path params: `{committee_uid}` +- Always include `BearerTokenAttribute()` and `VersionAttribute()` in payloads +- Always declare all error types that might be returned diff --git a/.claude/skills/develop/references/nats-patterns.md b/.claude/skills/develop/references/nats-patterns.md new file mode 100644 index 0000000..a1f1261 --- /dev/null +++ b/.claude/skills/develop/references/nats-patterns.md @@ -0,0 +1,188 @@ +# NATS Key-Value Storage Patterns + +This service uses NATS JetStream key-value (KV) buckets as its database. Think of each bucket as a table — you store and retrieve JSON-encoded data by a string key (usually a UID). + +## The Three Buckets + +| Bucket | Constant | Stores | +|--------|----------|--------| +| `committees` | `constants.KVBucketNameCommittees` | Committee base data | +| `committee-settings` | `constants.KVBucketNameCommitteeSettings` | Committee settings | +| `committee-members` | `constants.KVBucketNameCommitteeMembers` | Committee member data | + +## Adding a New Bucket + +Each new data entity that needs its own storage requires a bucket. You need to create it in two places: locally for development, and in the Helm chart for Kubernetes deployments. + +### 1. Local development (nats CLI) + +Create the bucket manually using the `nats` CLI once your local NATS server is running: + +```bash +nats kv add \ + --history=20 \ + --storage=file \ + --max-value-size=10485760 \ + --max-bucket-size=1073741824 +``` + +Replace `` with the actual bucket name (e.g. `committee-invites`). Use the same defaults as the existing buckets: history=20, file storage, 10MB max value size, 1GB max bucket size. + +### 2. Kubernetes deployment (Helm chart) + +Two files must be updated: + +**Step 1 — Add the bucket config to `charts/lfx-v2-committee-service/values.yaml`:** + +```yaml + # _kv_bucket is the configuration for the KV bucket for storing + _kv_bucket: + creation: true + keep: true + name: + history: 20 + storage: file + maxValueSize: 10485760 # 10MB + maxBytes: 1073741824 # 1GB + compression: true +``` + +Follow the naming convention: `_kv_bucket` for the key (e.g. `committee_invites_kv_bucket`). + +**Step 2 — Add a `KeyValue` CRD block to `charts/lfx-v2-committee-service/templates/nats-kv-buckets.yaml`:** + +```yaml +--- +{{- if .Values.nats._kv_bucket.creation }} +apiVersion: jetstream.nats.io/v1beta2 +kind: KeyValue +metadata: + name: {{ .Values.nats._kv_bucket.name }} + namespace: {{ .Release.Namespace }} + {{- if .Values.nats._kv_bucket.keep }} + annotations: + "helm.sh/resource-policy": keep + {{- end }} +spec: + bucket: {{ .Values.nats._kv_bucket.name }} + history: {{ .Values.nats._kv_bucket.history }} + storage: {{ .Values.nats._kv_bucket.storage }} + maxValueSize: {{ .Values.nats._kv_bucket.maxValueSize }} + maxBytes: {{ .Values.nats._kv_bucket.maxBytes }} + compression: {{ .Values.nats._kv_bucket.compression }} +{{- end }} +``` + +Add a `---` separator before each new block. The `keep: true` annotation tells Helm to preserve the bucket (and its data) when the chart is uninstalled — always set this to `true` for production buckets. + +### 3. Register the bucket constant + +Add the bucket name as a constant in `pkg/constants/storage.go` alongside the existing ones, then initialize it in the NATS client in `internal/infrastructure/nats/client.go`. + +## How Data Flows + +The storage layer lives in `internal/infrastructure/nats/storage.go`. It implements the port interfaces from `internal/domain/port/`. + +All storage operations follow this pattern: +1. Marshal the domain model to JSON (`json.Marshal`) +2. Call the NATS KV method (`Create`, `Put`, `Get`, `Delete`) +3. Handle errors using `pkg/errors/` +4. Log debug info with `slog.DebugContext` + +## Port Interfaces + +The service layer never calls NATS directly — it calls interfaces: + +```go +// CommitteeBaseReader — read operations +type CommitteeBaseReader interface { + GetBase(ctx context.Context, uid string) (*model.CommitteeBase, uint64, error) + GetRevision(ctx context.Context, uid string) (uint64, error) +} + +// CommitteeBaseWriter — write operations +type CommitteeBaseWriter interface { + Create(ctx context.Context, committee *model.Committee) error + UpdateBase(ctx context.Context, committee *model.Committee, revision uint64) error + Delete(ctx context.Context, uid string, revision uint64) error + UniqueNameProject(ctx context.Context, committee *model.Committee) (string, error) + UniqueSSOGroupName(ctx context.Context, committee *model.Committee) (string, error) +} +``` + +When adding a new storage operation, **add it to the interface first**, then implement it in `storage.go`, then add a mock in `internal/infrastructure/mock/`. + +## NATS KV Operations + +### Create (insert, fails if key exists) +```go +rev, err := s.client.kvStore[constants.KVBucketNameCommittees].Create(ctx, uid, dataBytes) +if err != nil { + if errors.Is(err, jetstream.ErrKeyExists) { + return errs.NewConflict("committee already exists") + } + return errs.NewUnexpected("failed to create committee", err) +} +``` + +### Get (read by key) +```go +entry, err := s.client.kvStore[constants.KVBucketNameCommittees].Get(ctx, uid) +if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, 0, errs.NewNotFound("committee not found") + } + return nil, 0, errs.NewUnexpected("failed to get committee", err) +} +var base model.CommitteeBase +if err := json.Unmarshal(entry.Value(), &base); err != nil { + return nil, 0, errs.NewUnexpected("failed to unmarshal committee", err) +} +return &base, entry.Revision(), nil +``` + +### Update (optimistic concurrency — requires revision number) +```go +rev, err := s.client.kvStore[constants.KVBucketNameCommittees].Update(ctx, uid, dataBytes, revision) +if err != nil { + return errs.NewUnexpected("failed to update committee", err) +} +``` + +### Delete +```go +err := s.client.kvStore[constants.KVBucketNameCommittees].Delete(ctx, uid, jetstream.LastRevision(revision)) +if err != nil { + return errs.NewUnexpected("failed to delete committee", err) +} +``` + +## The Revision Number + +Every NATS KV entry has a revision number that increments on each write. Updates and deletes require passing the current revision — this is **optimistic concurrency control**. If two processes try to update the same entry simultaneously, only the first one succeeds; the second gets an error because the revision they passed no longer matches. + +This is why `GetBase` returns `(model, revision, error)` — callers need to pass that revision to subsequent updates. + +## Error Types + +Always use `pkg/errors/` for errors, not raw Go errors: +- `errs.NewNotFound("message")` — 404, resource doesn't exist +- `errs.NewConflict("message")` — 409, uniqueness violation +- `errs.NewValidation("message")` — 400, bad input +- `errs.NewUnexpected("message", cause)` — 500, unexpected error + +## Logging + +Use structured logging for storage operations: +```go +slog.DebugContext(ctx, "created committee in NATS storage", + "committee_uid", uid, + "revision", rev, +) +``` + +Use `DebugContext` for successful operations, `WarnContext` for recoverable issues. + +## Adding Storage to a Mock (for tests) + +When you add a method to a port interface, also add it to the corresponding mock in `internal/infrastructure/mock/`. Mocks implement the same interface but store data in memory, allowing tests to run without a real NATS server. diff --git a/.claude/skills/develop/references/v1-sync-patterns.md b/.claude/skills/develop/references/v1-sync-patterns.md new file mode 100644 index 0000000..eda8084 --- /dev/null +++ b/.claude/skills/develop/references/v1-sync-patterns.md @@ -0,0 +1,77 @@ +# V1 Sync Patterns + +When a data model change in this service (e.g. adding a new field to `CommitteeBase`) also needs to be reflected in the V1 system, three repos must be updated together. This document describes where exactly to make changes in `lfx-v1-sync-helper`. + +## Repos involved + +| Repo | Role | +| ---- | ---- | +| [`lfx-v2-committee-service`](https://github.com/linuxfoundation/lfx-v2-committee-service) | V2 domain model and API (this repo) | +| [`project-management`](https://github.com/linuxfoundation/project-management) | V1 API and data model | +| [`lfx-v1-sync-helper`](https://github.com/linuxfoundation/lfx-v1-sync-helper) | Sync service that bridges V1 ↔ V2 | + +## V2 → V1 direction (write happens in V2) + +When a committee is created or updated in V2, the data is indexed in OpenSearch and triggers the sync helper to write it to V1 (PostgreSQL via the V1 Project Service API). + +**File:** [`cmd/lfx-v1-sync-helper/ingest_indexer.go`](https://github.com/linuxfoundation/lfx-v1-sync-helper/blob/main/cmd/lfx-v1-sync-helper/ingest_indexer.go) + +The relevant functions that build the V1 payload from V2 data are: + +- `syncCommitteeCreateToV1` — handles committee create events from V2 +- `syncCommitteeUpdateToV1` — handles committee update events from V2 + +Add your new field to the `projectServiceCommitteeCreate` / `projectServiceCommitteeUpdate` payload struct and map it from the V2 `data` map inside these functions. Example: + +```go +// In syncCommitteeUpdateToV1, add to the payload: +ChatChannel: data["chat_channel"], +``` + +If the field requires a value transformation between V2 and V1 formats (like `category` does), add a dedicated mapper function following the pattern of `mapV2CategoryToV1`. + +## V1 → V2 direction (write happens in V1) + +When a committee is created or updated in V1 (sourced from Salesforce), the sync helper calls the V2 committee service API to upsert the record. + +**File:** [`cmd/lfx-v1-sync-helper/handlers_committees.go`](https://github.com/linuxfoundation/lfx-v1-sync-helper/blob/main/cmd/lfx-v1-sync-helper/handlers_committees.go) + +The relevant functions that build the V2 payload from V1 Salesforce data are: + +- `mapV1DataToCommitteeCreatePayload` — used when creating a committee in V2 from a V1 event +- `mapV1DataToCommitteeUpdateBasePayload` — used when updating a committee in V2 from a V1 event + +Add your new field to the `CreateCommitteePayload` / `UpdateCommitteeBasePayload` struct mapping inside these functions. The V1 field names come from the raw Salesforce field map (e.g. `v1Data["chat_channel__c"]`). Example: + +```go +// In mapV1DataToCommitteeCreatePayload: +ChatChannel: v1Data["chat_channel__c"], +``` + +If the field requires a value transformation (like `type__c` → category does via `mapTypeToCategory`), add a dedicated mapper function. + +## Module versioning when working in parallel + +The `lfx-v1-sync-helper` imports this repo as a Go module to use the V2 data model types: + +``` +github.com/linuxfoundation/lfx-v2-committee-service vX.Y.Z +``` + +If your V2 model changes haven't been released and tagged yet, the new fields won't be visible in the sync helper. To develop both in parallel, point the sync helper's `go.mod` to your branch: + +```bash +# In lfx-v1-sync-helper +go get github.com/linuxfoundation/lfx-v2-committee-service@ +``` + +Revert to a tagged version before merging the sync helper PR. Coordinate timing so the V2 tag exists before the sync helper is merged. + +## Checklist for a cross-system field change + +- [ ] Add the field to the V2 domain model (`internal/domain/model/`) +- [ ] Expose it in the V2 API design (`cmd/committee-api/design/`) +- [ ] Add the field to the V1 data model and API in `project-management` +- [ ] Update `syncCommitteeCreateToV1` / `syncCommitteeUpdateToV1` in `ingest_indexer.go` +- [ ] Update `mapV1DataToCommitteeCreatePayload` / `mapV1DataToCommitteeUpdateBasePayload` in `handlers_committees.go` +- [ ] If V2 changes are not yet tagged, update `go.mod` in the sync helper to point to the branch; revert before merging diff --git a/.claude/skills/preflight/SKILL.md b/.claude/skills/preflight/SKILL.md new file mode 100644 index 0000000..7fe2e41 --- /dev/null +++ b/.claude/skills/preflight/SKILL.md @@ -0,0 +1,226 @@ +--- +name: preflight +description: > + Pre-PR validation — format, lint, license headers, tests, build, and + protected file check. Use before submitting any PR, to check if code is + ready, validate changes, or verify a branch is clean and ready for review. +allowed-tools: Bash, Read, Glob, Grep, AskUserQuestion +--- + +# Pre-Submission Preflight Check + +You are running a comprehensive validation before the contributor submits a pull request. Run each check in order, report results clearly, and help fix any issues found. + +## Check 0: Working Tree Status + +Before running any validation, understand what has changed: + +```bash +git status +git diff --stat origin/main...HEAD +git log --format="%h %s%n%b" origin/main...HEAD +``` + +**Evaluate:** +- **Uncommitted changes?** — Ask the contributor: should we commit them now, or are they intentionally unstaged? +- **No commits ahead of main?** — The branch has nothing to validate. Ask if they're on the right branch. +- **Commit messages missing JIRA ticket?** — Flag commits that don't include `LFXV2-` references. +- **Commits missing `Signed-off-by`?** — Flag any commits without this line (visible in the full log output above). + +Resolve any blockers before proceeding. + +## Check 1: Code Formatting + +```bash +make fmt +``` + +This formats all Go files to the project's standard style. It modifies files in place. + +> **Why do this first:** Formatting issues would otherwise show up as lint errors, creating noise. Formatting first cleans those up automatically. + +If files were modified, check what changed: +```bash +git diff --stat +``` + +If formatting changed files, remind the contributor to commit those changes before submitting the PR. + +## Check 2: License Headers + +```bash +make license-check +``` + +Every source file (`.go`, `.html`, `.txt`) must start with: + +``` +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT +``` + +If any files are missing headers, add them to the top of each file before the package declaration: + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package yourpackage +``` + +## Check 3: Linting + +```bash +make lint +``` + +The linter checks for code quality issues: unused variables, missing error handling, stylistic problems, and more. Fix any errors reported. + +Common issues and fixes: +- **"declared and not used"** — Remove the unused variable or use it +- **"error return value not checked"** — Add `if err != nil { ... }` handling +- **Formatting issues** — Run `make fmt` again (should have been caught in Check 1) + +### Re-validate after fixes + +If you fixed anything in Checks 1–3, re-run lint to confirm the fixes are clean: +```bash +make lint +``` + +## Check 4: Tests + +```bash +make test +``` + +All tests must pass. If tests fail: +- Read the failure output carefully — it usually points to the exact file and line +- Check if you changed something that broke an existing test +- If you added new code, check that there are tests covering it + +If adding new tests, run them first to confirm they pass: +```bash +go test -v ./internal/service/... # example: test a specific package +``` + +## Check 5: Build Verification + +```bash +make build +``` + +The service must compile without errors. Build failures typically mean: +- Type errors (wrong type passed to a function) +- Missing imports (forgot to import a new package) +- Interface not fully implemented (added a method to a port but didn't implement it in the NATS layer or mock) + +If you recently ran `make apigen` (after modifying design files), make sure you've implemented any new handler methods that Goa expects. + +## Check 6: Protected Files + +Check that no infrastructure files were accidentally modified: + +```bash +git diff --name-only origin/main...HEAD +``` + +**Flag changes to any of these files** — they should NOT be modified without code owner approval: + +- `cmd/committee-api/main.go` +- `cmd/committee-api/http.go` +- `internal/middleware/` +- `internal/infrastructure/auth/` +- `internal/infrastructure/nats/client.go` +- `charts/` +- `.github/workflows/` +- `Makefile` +- `go.mod` / `go.sum` +- `Dockerfile` + +If protected files appear in the diff, ask the contributor whether those changes were intentional. If they were accidental, help them revert just those files: +```bash +git checkout origin/main -- +``` + +## Check 7: Commit Verification + +Verify all changes are properly committed and follow conventions: + +```bash +git status +git log --format="%h %s%n%b" origin/main...HEAD +``` + +**For each commit, verify:** +- Message format: `type(LFXV2-): short description` + - Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore` +- Has `Signed-off-by: Name ` line (the `-s` flag in git commit adds this) +- References a JIRA ticket + +If the last commit is missing sign-off and hasn't been pushed yet, it can be amended: +```bash +git commit --amend -s --no-edit +``` + +## Check 8: Change Summary + +Generate a summary for the PR description: + +```bash +git diff --stat origin/main...HEAD +``` + +List: +1. **New files** — what they do +2. **Modified files** — what changed +3. **New endpoints** — HTTP method + path +4. **Domain model changes** — any new or modified structs +5. **How to test manually** — curl command or steps to verify the feature works + +## Results Report + +Present a clear pass/fail summary: + +``` +PREFLIGHT RESULTS +───────────────────────────────── +✓ Working tree — Clean, N commits ahead of main +✓ Formatting — Applied / Already clean +✓ License headers — All files have headers +✓ Linting — No errors +✓ Tests — All passed +✓ Build — Succeeded +✓ Protected files — None modified +✓ Commits — Conventions followed, signed off +───────────────────────────────── +READY FOR PR +``` + +Or with issues: + +``` +PREFLIGHT RESULTS +───────────────────────────────── +✓ Working tree — Clean, N commits ahead of main +✓ Formatting — Applied +✓ License headers — All files have headers +✗ Linting — 2 errors (see above) +✗ Tests — 1 failure (see above) +✓ Build — Succeeded +✓ Protected files — None modified +✓ Commits — Conventions followed, signed off +───────────────────────────────── +ISSUES FOUND — Fix before submitting +``` + +## If All Checks Pass + +Offer to create the PR: + +> "All preflight checks passed! Ready to create a PR. Would you like me to create it with `gh pr create`?" + +When creating the PR, include in the description: +- What the change does (from the change summary) +- How to test it manually +- Reference to the JIRA ticket diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md new file mode 100644 index 0000000..265d3bc --- /dev/null +++ b/.claude/skills/setup/SKILL.md @@ -0,0 +1,365 @@ +--- +name: setup +description: > + Development environment setup from zero — prerequisites, clone, NATS server, + environment variables, code generation, and running the service. Use for + getting started, first-time setup, broken environments, or when the service + won't start. Covers both local mode (fast iteration, no Kubernetes) and E2E + mode (full stack with Traefik, Heimdall, OpenFGA via OrbStack). +allowed-tools: Bash, Read, Glob, Grep, AskUserQuestion +--- + +# Development Environment Setup + +You are helping a contributor set up the LFX V2 Committee Service for local development. Walk through each step interactively, verifying success before moving on. Explain what each step does in plain language — avoid jargon where possible. + +## What This Service Is + +The committee service is the backend that manages committees and committee members for the LFX platform. It stores data in NATS (a fast messaging system that also acts as a key-value database) and exposes a REST API. + +## Step 1: Choose Your Development Mode + +Before setting up anything, ask the contributor which mode they need: + +> "There are two ways to run this service locally. Which fits what you're trying to do? +> +> **Local mode** — Run the Go binary directly on your machine. Auth is mocked out, so you don't need any Kubernetes infrastructure. Fast to start, fast to iterate. Best for: features and bug fixes that are self-contained within this service — no dependencies on other LFX platform services. +> +> **E2E mode** — Deploy the service to a local Kubernetes cluster (OrbStack) alongside the full LFX platform stack: Traefik for routing, Heimdall for authentication, OpenFGA for authorization, and real NATS. Best for: features that interact with other platform services, testing auth/authz flows, or validating Helm chart changes before a PR." + +Once they've chosen, follow the relevant path below. Steps 2–5 are common to both modes. + +--- + +## Steps 2–5: Common Setup (Both Modes) + +### Step 2: Prerequisites + +Check that the following tools are installed: + +#### Go 1.24+ + +```bash +go version +``` + +Go is the programming language this service is written in. If missing, install from [go.dev/doc/install](https://go.dev/doc/install) and select version 1.24 or newer. + +#### Git + +```bash +git --version +``` + +If missing, install from [git-scm.com](https://git-scm.com). + +#### GitHub CLI (gh) + +```bash +gh --version +``` + +Used for working with pull requests. If missing: [cli.github.com](https://cli.github.com). After installing, run `gh auth login` to authenticate. + +#### NATS CLI + +```bash +nats --version +``` + +The NATS command-line tool. On macOS: `brew install nats-io/nats-tools/nats`. + +### Step 3: Clone the Repository + +If not already cloned: +```bash +git clone https://github.com/linuxfoundation/lfx-v2-committee-service.git +cd lfx-v2-committee-service +``` + +If already in the repo, confirm the location: +```bash +pwd +git remote -v +``` + +### Step 4: Install Go Dependencies + +```bash +make setup +make deps +``` + +- `make setup` downloads all Go packages the service needs +- `make deps` installs the Goa code generation tool (more on this in the develop skill) + +Verify both completed without errors. + +### Step 5: Install Development Tools + +```bash +make setup-dev +``` + +This installs the linter used to check code quality. + +--- + +## Local Mode Setup + +Follow this path if you chose **local mode** in Step 1. + +### Local Step 1: Additional Prerequisites + +#### NATS Server + +```bash +nats-server --version +``` + +NATS is the database this service uses locally. If missing, on macOS: `brew install nats-server`. + +### Local Step 2: Environment Variables + +The service reads its configuration from a `.env` file. An example file is provided — copy it to create your own local config: + +```bash +cp .env.example .env +``` + +> **Why copy instead of using it directly?** `.env` is gitignored so you can modify it freely (add secrets, change settings) without accidentally pushing those changes. Never edit `.env.example` with real credentials. + +Load the environment: +```bash +source .env +``` + +Open `.env` to see what each variable does — every line has a comment explaining it. The defaults are pre-configured for local mode with auth mocked out, so you can call any endpoint without real credentials. + +Verify the key variables are set: +```bash +echo "NATS_URL: $NATS_URL" +echo "AUTH_SOURCE: $AUTH_SOURCE" +echo "JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL: $JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL" +``` + +### Local Step 3: Start NATS + +The service needs NATS running as both a message broker and key-value database. Open a new terminal and start it: + +```bash +nats-server -js +``` + +The `-js` flag enables JetStream, NATS's persistence feature (required for key-value storage). Leave this terminal open — the server runs in the foreground. + +Now, in the original terminal, override the NATS URL to point to your local instance: +```bash +export NATS_URL=nats://localhost:4222 +``` + +### Local Step 4: Create NATS Key-Value Buckets + +The service uses buckets in NATS to store data — think of each as a table in a traditional database. Create them: + +```bash +nats kv add committees \ + --history=20 --storage=file \ + --max-value-size=10485760 --max-bucket-size=1073741824 + +nats kv add committee-settings \ + --history=20 --storage=file \ + --max-value-size=10485760 --max-bucket-size=1073741824 + +nats kv add committee-members \ + --history=20 --storage=file \ + --max-value-size=10485760 --max-bucket-size=1073741824 +``` + +Verify: +```bash +nats kv ls +``` + +Expected output: +``` +committee-members +committee-settings +committees +``` + +> These buckets only need to be created once. If you restart NATS with the same data directory, they'll still be there. + +### Local Step 5: Generate API Code + +```bash +make apigen +``` + +This reads the design files in `cmd/committee-api/design/` and generates the HTTP layer in `gen/`. Think of it like a compiler for your API definition. + +### Local Step 6: Build and Run + +```bash +make run +``` + +This builds the Go binary and starts the service. You should see log output indicating it started successfully. + +### Local Step 7: Verify + +```bash +curl http://localhost:8080/livez +``` + +Expected response: `OK` + +Once you see `OK`, the service is running and you're ready to develop. **Next step:** Use `/develop` to build or modify a feature. + +### Local Mode Troubleshooting + +**"connection refused" when running the service:** + +- Make sure NATS is running (`nats-server -js` in a separate terminal) +- Check `$NATS_URL` is set to `nats://localhost:4222` + +**NATS bucket already exists error:** + +- That's fine — the buckets already exist from a previous run. Continue. + +**Build errors after `make run`:** + +- Run `make apigen` first, then `make run` +- If errors persist, run `make setup` to re-download dependencies + +**Port 8080 already in use:** + +- Find the process: `lsof -i :8080` +- Either stop it or run on a different port: `export PORT=8081` + +--- + +## E2E Mode Setup + +Follow this path if you chose **E2E mode** in Step 1. This runs the service inside a local Kubernetes cluster alongside the full LFX platform stack. + +### E2E Step 1: Additional Prerequisites + +#### OrbStack + +OrbStack provides a lightweight local Kubernetes cluster on macOS. Install from [orbstack.dev](https://orbstack.dev), then enable Kubernetes in OrbStack's settings (Settings → Kubernetes → Enable). + +Verify: + +```bash +kubectl get nodes +``` + +You should see a single node in `Ready` state. + +#### Helm + +```bash +helm version +``` + +Helm is the package manager for Kubernetes — it's how we deploy the service and its dependencies. If missing: `brew install helm`. + +### E2E Step 2: Install the LFX Platform Chart + +The committee service depends on a shared platform chart that installs Traefik (routing), Heimdall (authentication), OpenFGA (authorization), NATS, and OpenSearch into your cluster. + +Create the namespace and install the platform chart from the OCI registry: + +```bash +kubectl create namespace lfx + +helm install -n lfx lfx-platform \ + oci://ghcr.io/linuxfoundation/lfx-v2-helm/chart/lfx-platform \ + --version 0.1.1 \ + --set lfx-v2-committee-service.enabled=false +``` + +> **Why `--set lfx-v2-committee-service.enabled=false`?** The platform chart includes the committee service as a subchart. We disable it here so we can deploy our own local version from this repo instead — otherwise there would be two conflicting committee service deployments in the cluster. + +Wait for all pods to be ready: +```bash +kubectl get pods -n lfx --watch +``` + +All pods should reach `Running` status before continuing. This may take a few minutes on first install as images are pulled. + +### E2E Step 3: Generate API Code + +```bash +make apigen +``` + +Same as local mode — generates the HTTP layer from the Goa design files. + +### E2E Step 4: Deploy the Committee Service + +Deploy the service chart from this repo into the cluster: + +```bash +make helm-install +``` + +This runs `helm upgrade --install lfx-v2-committee-service ./charts/lfx-v2-committee-service --namespace lfx`. + +Wait for the committee service pod to be ready: +```bash +kubectl get pods -n lfx -l app.kubernetes.io/name=lfx-v2-committee-service --watch +``` + +### E2E Step 5: Verify + +Check the service is reachable through Traefik: +```bash +curl http://committees.k8s.orb.local/livez +``` + +Expected response: `OK` + +> **What's different from local mode:** Requests now go through Traefik (the API gateway) and Heimdall (the auth middleware). Calls to most endpoints require a real JWT token. Check the platform documentation for how to obtain a token for local testing. + +### E2E Step 6: Iterating + +When you make code changes and want to test them in the cluster: + +```bash +make docker-build # Build a new Docker image +make helm-install # Redeploy (upgrade) the Helm release +``` + +To restart the pod after a config change without rebuilding: + +```bash +kubectl rollout restart deployment/lfx-v2-committee-service -n lfx +``` + +To tear down just the committee service (without removing the platform): + +```bash +make helm-uninstall +``` + +### E2E Mode Troubleshooting + +**Pods stuck in `Pending`:** + +- Check events: `kubectl describe pod -n lfx` +- Usually a resource or image pull issue + +**`helm install` fails with "already exists":** + +- Use `helm upgrade` instead, or uninstall first: `helm uninstall lfx-platform -n lfx` + +**`committees.k8s.orb.local` not resolving:** + +- OrbStack automatically handles `.orb.local` DNS — make sure OrbStack is running +- Check Traefik is running: `kubectl get pods -n lfx | grep traefik` + +**Auth errors on endpoints:** + +- In E2E mode, auth is real. You need a valid JWT. Check the platform docs for obtaining a local token, or switch to local mode if your feature doesn't require cross-service integration. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c640964 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# This is an example environment file for local development. +# Copy it to .env before running the service: +# +# cp .env.example .env +# +# .env is gitignored — you can modify it freely without it being pushed to the repo. + +# NATS_URL is the URL of the NATS server. +# For local mode (running the Go binary directly), override this to nats://localhost:4222 after sourcing. +# For E2E mode (OrbStack), this value points to NATS running inside the cluster. +export NATS_URL=nats://lfx-platform-nats.lfx.svc.cluster.local:4222 + +# LOG_LEVEL controls the verbosity of service logs (debug, info, warn, error). +export LOG_LEVEL=debug + +# JWKS_URL is the URL to the JSON Web Key Set endpoint used to validate JWT tokens. +# In local mode this is unused (auth is mocked). In E2E mode it points to Heimdall inside the cluster. +export JWKS_URL=http://lfx-platform-heimdall.lfx.svc.cluster.local:4457/.well-known/jwks + +# JWT_AUDIENCE is the expected audience claim in incoming JWT tokens. +export JWT_AUDIENCE=lfx-v2-committee-service + +# JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL sets the mock principal used when AUTH_SOURCE=mock. +# This simulates a logged-in user with project_super_admin role for local development. +export JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL=project_super_admin + +# AUTH_SOURCE controls the authentication backend. +# "mock" disables real auth and uses the mock principal above — only use for local development. +# "jwt" enables real JWT validation via JWKS_URL. +export AUTH_SOURCE=mock diff --git a/cmd/committee-api/README.md b/cmd/committee-api/README.md index 53c45c7..1ba9911 100644 --- a/cmd/committee-api/README.md +++ b/cmd/committee-api/README.md @@ -42,6 +42,7 @@ In addition to HTTP endpoints, this service provides NATS messaging capabilities ### Usage Examples #### Get Committee Name + ```bash # Send request with committee UID as message data nats request lfx.committee-api.get_name "061a110a-7c38-4cd3-bfcf-fc8511a37f35" @@ -49,6 +50,7 @@ nats request lfx.committee-api.get_name "061a110a-7c38-4cd3-bfcf-fc8511a37f35" ``` #### List Committee Members + ```bash # Send request with committee UID as message data nats request lfx.committee-api.list_members "061a110a-7c38-4cd3-bfcf-fc8511a37f35" @@ -58,10 +60,12 @@ nats request lfx.committee-api.list_members "061a110a-7c38-4cd3-bfcf-fc8511a37f3 ### Error Handling NATS message responses follow this format: + - **Success**: Direct data response (string for name, JSON for members) - **Error**: JSON object with error message: `{"error": "error description"}` Common error scenarios: + - Invalid UUID format: `{"error": "invalid UUID format"}` - Committee not found: `{"error": "committee with UID not found"}` - Committee has no members: `[]` (empty array for list_members) @@ -70,12 +74,11 @@ Common error scenarios: ```bash ├── design/ # Goa design files -│ ├── committee.go # Goa committee service specification -│ └── type.go # Goa data types and models +│ ├── committee_svc.go # Service and endpoint definitions +│ └── ... # Type files for each entity ├── service/ # Service implementation (presentation layer) -│ ├── committee_service.go # Committee and member service implementation -│ ├── error.go # Error handling utilities -│ └── providers.go # Dependency injection providers +│ ├── committee_service.go # HTTP handler implementations +│ └── ... # Response mappers, validators, error handling ├── main.go # Application startup and dependency injection ├── http.go # HTTP server setup and configuration └── README.md # This documentation @@ -167,7 +170,7 @@ Tags serve multiple important purposes in the LFX system: 1. **Indexed Search**: Tags are indexed in OpenSearch, enabling fast lookups and text searches across committees and members -2. **Relationship Navigation**: +2. **Relationship Navigation**: - Parent-child committee relationships can be traversed using the parent_uid tags - Committee-project relationships can be traversed using the project_uid tags - Committee-member relationships can be traversed using the committee_uid tags @@ -321,6 +324,7 @@ The service relies on some resources and external services being spun up prior t # Note: replace the hostname with the host from ./charts/lfx-v2-committee-service/ingressroute.yaml curl http://lfx-api.k8s.orb.local/livez ``` + ### Authorization with OpenFGA When deployed via Kubernetes, the committee service uses OpenFGA for fine-grained authorization control. The authorization is handled by Heimdall middleware before requests reach the service. @@ -348,7 +352,7 @@ For local development without OpenFGA: Note: follow the [Development Workflow](#4-development-workflow) section on how to run the service code -1. **Update design files**: Edit the committee design file in `design/committee.go` to include specification of the new endpoint with all of its supported parameters, responses, and errors, etc. +1. **Update design files**: Edit the committee design file in `design/committee_svc.go` to include specification of the new endpoint with all of its supported parameters, responses, and errors, etc. 2. **Regenerate code**: Run `make apigen` after design changes to generate the new Goa interfaces and types 3. **Implement code**: Implement the new endpoint in `service/` following the existing patterns. Add the necessary business logic to the use case layer in `internal/service/` if needed. Include comprehensive tests for the new endpoint. 4. **Update heimdall ruleset**: Ensure that `/charts/lfx-v2-committee-service/templates/ruleset.yaml` has the route and method for the endpoint set so that authentication is configured when deployed. If the endpoint modifies data (PUT, DELETE, PATCH), consider adding OpenFGA authorization checks in the ruleset for proper access control @@ -361,4 +365,4 @@ For complex committee operations that require multiple steps or external service 2. **Update Domain Models**: Modify committee models in `internal/domain/model/` if new data structures are needed 3. **Extend Port Interfaces**: Update port interfaces in `internal/domain/port/` to support new storage or external service operations 4. **Implement Infrastructure**: Add concrete implementations in `internal/infrastructure/` for new external service integrations -5. **Add Tests**: Create comprehensive unit tests with mocks for all new components \ No newline at end of file +5. **Add Tests**: Create comprehensive unit tests with mocks for all new components diff --git a/cmd/committee-api/design/committee_member_type.go b/cmd/committee-api/design/committee_member_type.go new file mode 100644 index 0000000..d0c9e97 --- /dev/null +++ b/cmd/committee-api/design/committee_member_type.go @@ -0,0 +1,305 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package design + +import ( + "goa.design/goa/v3/dsl" +) + +// CommitteeMemberBase is the DSL type for a committee member base. +var CommitteeMemberBase = dsl.Type("committee-member-base", func() { + dsl.Description("A base representation of committee members.") + + CommitteeMemberBaseAttributes() +}) + +// CommitteeMemberBaseAttributes defines the base attributes for a committee member. +func CommitteeMemberBaseAttributes() { + UsernameAttribute() + EmailAttribute() + FirstNameAttribute() + LastNameAttribute() + JobTitleAttribute() + LinkedInProfileAttribute() + RoleInfoAttributes() + AppointedByAttribute() + StatusAttribute() + VotingInfoAttributes() + OrganizationInfoAttributes() +} + +// CommitteeMemberFull is the DSL type for a complete committee member. +var CommitteeMemberFull = dsl.Type("committee-member-full", func() { + dsl.Description("A complete representation of committee members with all attributes.") + + CommitteeMemberBaseAttributes() +}) + +// CommitteeMemberFullWithReadonlyAttributes is the DSL type for a complete committee member with readonly attributes. +var CommitteeMemberFullWithReadonlyAttributes = dsl.Type("committee-member-full-with-readonly-attributes", func() { + dsl.Description("A complete representation of committee members with readonly attributes.") + + CommitteeMemberUIDAttribute() + CommitteeUIDMemberAttribute() + CommitteeNameMemberAttribute() + CommitteeCategoryMemberAttribute() + CommitteeMemberBaseAttributes() + CreatedAtAttribute() + UpdatedAtAttribute() +}) + +// CommitteeMemberCreateAttributes defines attributes for creating a committee member. +func CommitteeMemberCreateAttributes() { + CommitteeMemberBaseAttributes() +} + +// CommitteeMemberUpdateAttributes defines attributes for updating a committee member. +func CommitteeMemberUpdateAttributes() { + CommitteeMemberBaseAttributes() +} + +// OrganizationInfoAttributes defines organization information attributes for a committee member. +func OrganizationInfoAttributes() { + dsl.Attribute("organization", func() { + dsl.Description("Organization information for the committee member") + OrganizationIDAttribute() + OrganizationNameAttribute() + OrganizationWebsiteAttribute() + }) +} + +// RoleInfoAttributes defines role information attributes for a committee member. +func RoleInfoAttributes() { + dsl.Attribute("role", func() { + dsl.Description("Committee role information") + RoleNameAttribute() + RoleStartDateAttribute() + RoleEndDateAttribute() + }) +} + +// VotingInfoAttributes defines voting information attributes for a committee member. +func VotingInfoAttributes() { + dsl.Attribute("voting", func() { + dsl.Description("Voting information for the committee member") + VotingStatusAttribute() + VotingStartDateAttribute() + VotingEndDateAttribute() + }) +} + +// CommitteeMemberUIDAttribute is the DSL attribute for committee member UID. +func CommitteeMemberUIDAttribute() { + dsl.Attribute("uid", dsl.String, "Committee member UID -- v2 uid, not related to v1 id directly", func() { + dsl.Example("2200b646-fbb2-4de7-ad80-fd195a874baf") + dsl.Format(dsl.FormatUUID) + }) +} + +// CommitteeUIDMemberAttribute is the DSL attribute for committee UID in member context. +func CommitteeUIDMemberAttribute() { + dsl.Attribute("committee_uid", dsl.String, "Committee UID -- v2 uid, not related to v1 id directly", func() { + dsl.Example("7cad5a8d-19d0-41a4-81a6-043453daf9ee") + dsl.Format(dsl.FormatUUID) + }) +} + +// CommitteeNameMemberAttribute is the DSL attribute for committee name in member context. +func CommitteeNameMemberAttribute() { + dsl.Attribute("committee_name", dsl.String, "The name of the committee this member belongs to", func() { + dsl.MaxLength(100) + dsl.Example("Technical Steering Committee") + }) +} + +// CommitteeCategoryMemberAttribute is the DSL attribute for committee category in member context. +func CommitteeCategoryMemberAttribute() { + dsl.Attribute("committee_category", dsl.String, "The category of the committee this member belongs to", func() { + dsl.MaxLength(100) + dsl.Example("Board") + }) +} + +// MemberUIDAttribute is the DSL attribute for member UID in URL paths. +func MemberUIDAttribute() { + dsl.Attribute("member_uid", dsl.String, "Committee member UID -- v2 uid, not related to v1 id directly", func() { + dsl.Example("2200b646-fbb2-4de7-ad80-fd195a874baf") + dsl.Format(dsl.FormatUUID) + }) +} + +// UsernameAttribute is the DSL attribute for username. +func UsernameAttribute() { + dsl.Attribute("username", dsl.String, "User's LF ID", func() { + dsl.MaxLength(100) + dsl.Example("user123") + }) +} + +// EmailAttribute is the DSL attribute for email. +func EmailAttribute() { + dsl.Attribute("email", dsl.String, "Primary email address", func() { + dsl.Format(dsl.FormatEmail) + dsl.Example("user@example.com") + }) +} + +// FirstNameAttribute is the DSL attribute for first name. +func FirstNameAttribute() { + dsl.Attribute("first_name", dsl.String, "First name", func() { + dsl.MaxLength(100) + dsl.Example("John") + }) +} + +// LastNameAttribute is the DSL attribute for last name. +func LastNameAttribute() { + dsl.Attribute("last_name", dsl.String, "Last name", func() { + dsl.MaxLength(100) + dsl.Example("Doe") + }) +} + +// JobTitleAttribute is the DSL attribute for job title. +func JobTitleAttribute() { + dsl.Attribute("job_title", dsl.String, "Job title at organization", func() { + dsl.MaxLength(200) + dsl.Example("Chief Technology Officer") + }) +} + +// LinkedInProfileAttribute is the DSL attribute for LinkedIn profile URL. +func LinkedInProfileAttribute() { + dsl.Attribute("linkedin_profile", dsl.String, "LinkedIn profile URL", func() { + dsl.Format(dsl.FormatURI) + dsl.Pattern(`^(https?://)?([a-z]{2,3}\.)?linkedin\.com/.*$`) + dsl.Example("https://www.linkedin.com/in/johndoe") + }) +} + +// RoleNameAttribute is the DSL attribute for committee role name. +func RoleNameAttribute() { + dsl.Attribute("name", dsl.String, "Committee role name", func() { + dsl.Enum( + "Chair", + "Counsel", + "Developer Seat", + "TAC/TOC Representative", + "Director", + "Lead", + "None", + "Secretary", + "Treasurer", + "Vice Chair", + "LF Staff", + ) + dsl.Default("None") + dsl.Example("Chair") + }) +} + +// RoleStartDateAttribute is the DSL attribute for role start date. +func RoleStartDateAttribute() { + dsl.Attribute("start_date", dsl.String, "Role start date", func() { + dsl.Format(dsl.FormatDate) + dsl.Example("2023-01-01") + }) +} + +// RoleEndDateAttribute is the DSL attribute for role end date. +func RoleEndDateAttribute() { + dsl.Attribute("end_date", dsl.String, "Role end date", func() { + dsl.Format(dsl.FormatDate) + dsl.Example("2024-12-31") + }) +} + +// AppointedByAttribute is the DSL attribute for appointed by. +func AppointedByAttribute() { + dsl.Attribute("appointed_by", dsl.String, "How the member was appointed", func() { + dsl.Enum( + "Community", + "Membership Entitlement", + "Vote of End User Member Class", + "Vote of TSC Committee", + "Vote of TAC Committee", + "Vote of Academic Member Class", + "Vote of Lab Member Class", + "Vote of Marketing Committee", + "Vote of Governing Board", + "Vote of General Member Class", + "Vote of End User Committee", + "Vote of TOC Committee", + "Vote of Gold Member Class", + "Vote of Silver Member Class", + "Vote of Strategic Membership Class", + "None", + ) + dsl.Default("None") + dsl.Example("Community") + }) +} + +// StatusAttribute is the DSL attribute for member status. +func StatusAttribute() { + dsl.Attribute("status", dsl.String, "Member status", func() { + dsl.Enum("Active", "Inactive") + dsl.Default("Active") + dsl.Example("Active") + }) +} + +// VotingStatusAttribute is the DSL attribute for voting status. +func VotingStatusAttribute() { + dsl.Attribute("status", dsl.String, "Voting status", func() { + dsl.Enum( + "Alternate Voting Rep", + "Observer", + "Voting Rep", + "Emeritus", + "None", + ) + dsl.Default("None") + dsl.Example("Voting Rep") + }) +} + +// VotingStartDateAttribute is the DSL attribute for voting start date. +func VotingStartDateAttribute() { + dsl.Attribute("start_date", dsl.String, "Voting start date", func() { + dsl.Format(dsl.FormatDate) + dsl.Example("2023-01-01") + }) +} + +// VotingEndDateAttribute is the DSL attribute for voting end date. +func VotingEndDateAttribute() { + dsl.Attribute("end_date", dsl.String, "Voting end date", func() { + dsl.Format(dsl.FormatDate) + dsl.Example("2024-12-31") + }) +} + +// OrganizationNameAttribute is the DSL attribute for organization name. +func OrganizationNameAttribute() { + dsl.Attribute("name", dsl.String, "Organization name", func() { + dsl.MaxLength(200) + dsl.Example("The Linux Foundation") + }) +} + +// OrganizationWebsiteAttribute is the DSL attribute for organization website. +func OrganizationWebsiteAttribute() { + dsl.Attribute("website", dsl.String, "Organization website URL", func() { + dsl.Format(dsl.FormatURI) + dsl.Example("https://linuxfoundation.org") + }) +} + +// OrganizationIDAttribute is the DSL attribute for organization ID. +func OrganizationIDAttribute() { + dsl.Attribute("id", dsl.String, "Organization ID", func() { + dsl.Example("org-123456") + }) +} diff --git a/cmd/committee-api/design/committee.go b/cmd/committee-api/design/committee_svc.go similarity index 100% rename from cmd/committee-api/design/committee.go rename to cmd/committee-api/design/committee_svc.go diff --git a/cmd/committee-api/design/committee_type.go b/cmd/committee-api/design/committee_type.go new file mode 100644 index 0000000..be0fede --- /dev/null +++ b/cmd/committee-api/design/committee_type.go @@ -0,0 +1,342 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package design + +import ( + "goa.design/goa/v3/dsl" +) + +// CommitteeBase is the DSL type for a committee base. +var CommitteeBase = dsl.Type("committee-base", func() { + dsl.Description("A base representation of LFX committees without sub-objects.") + + CommitteeBaseAttributes() + +}) + +// CommitteeBaseAttributes is the DSL attributes for a committee base. +func CommitteeBaseAttributes() { + ProjectUIDAttribute() + NameAttribute() + CategoryAttribute() + DescriptionAttribute() + WebsiteAttribute() + EnableVotingAttribute() + SSOGroupEnabledAttribute() + RequiresReviewAttribute() + PublicAttribute() + CalendarAttribute() + DisplayNameAttribute() + ParentCommitteeUIDAttribute() +} + +// CommitteeSettings is the DSL type for a committee settings. +var CommitteeSettings = dsl.Type("committee-settings", func() { + dsl.Description("A representation of LF Committee settings.") + + CommitteeSettingsAttributes() +}) + +// CommitteeSettingsAttributes is the DSL attributes for a committee settings. +func CommitteeSettingsAttributes() { + BusinessEmailRequiredAttribute() + LastReviewedAtAttribute() + LastReviewedByAttribute() + MemberVisibilityAttribute() + ShowMeetingAttendeesAttribute() +} + +// CommitteeFull is the DSL type for a committee full. +var CommitteeFull = dsl.Type("committee-full", func() { + dsl.Description("A full representation of LFX committees with sub-objects.") + + CommitteeBaseAttributes() + + CommitteeSettingsAttributes() + + WritersAttribute() + AuditorsAttribute() +}) + +var CommitteeBaseWithReadonlyAttributes = dsl.Type("committee-base-with-readonly-attributes", func() { + dsl.Description("A base representation of LFX committees with readonly attributes.") + + CommitteeUIDAttribute() + + CommitteeBaseAttributes() + + ProjectNameAttribute() + SSOGroupNameAttribute() + + TotalMembersAttribute() + TotalVotingReposAttribute() + +}) + +var CommitteeFullWithReadonlyAttributes = dsl.Type("committee-full-with-readonly-attributes", func() { + dsl.Description("A complete representation of LFX committees with base, settings and readonly attributes.") + + CommitteeUIDAttribute() + + CommitteeBaseAttributes() + + SSOGroupNameAttribute() + + TotalMembersAttribute() + TotalVotingReposAttribute() + + // Include settings attributes for complete representation + CommitteeSettingsAttributes() + + WritersAttribute() + AuditorsAttribute() + +}) + +var CommitteeSettingsWithReadonlyAttributes = dsl.Type("committee-settings-with-readonly-attributes", func() { + dsl.Description("A representation of LF Committee settings with readonly attributes.") + + CommitteeUIDAttribute() + + CommitteeSettingsAttributes() + + CreatedAtAttribute() + UpdatedAtAttribute() + +}) + +// CommitteeUIDAttribute is the DSL attribute for committee UID. +func CommitteeUIDAttribute() { + dsl.Attribute("uid", dsl.String, "Committee UID -- v2 uid, not related to v1 id directly", func() { + // Read-only attribute + dsl.Example("7cad5a8d-19d0-41a4-81a6-043453daf9ee") + dsl.Format(dsl.FormatUUID) + }) +} + +// ProjectUIDAttribute is the DSL attribute for project UID. +func ProjectUIDAttribute() { + dsl.Attribute("project_uid", dsl.String, "Project UID this committee belongs to -- v2 uid, not related to v1 id directly", func() { + // Read-only attribute + dsl.Example("7cad5a8d-19d0-41a4-81a6-043453daf9ee") + dsl.Format(dsl.FormatUUID) + }) +} + +// ProjectNameAttribute is the DSL attribute for project name. +func ProjectNameAttribute() { + dsl.Attribute("project_name", dsl.String, "The name of the project this committee belongs to", func() { + dsl.MaxLength(100) + dsl.Example("Linux Foundation Project") + }) +} + +// NameAttribute is the DSL attribute for committee name. +func NameAttribute() { + dsl.Attribute("name", dsl.String, "The name of the committee", func() { + dsl.MaxLength(100) + dsl.Example("Technical Steering Committee") + }) +} + +// CategoryAttribute is the DSL attribute for committee category. +func CategoryAttribute() { + dsl.Attribute("category", dsl.String, "The category of the committee", func() { + dsl.Enum( + "Ambassador", + "Board", + "Code of Conduct", + "Committers", + "Expert Group", + "Finance Committee", + "Government Advisory Council", + "Legal Committee", + "Maintainers", + "Marketing Committee/Sub Committee", + "Marketing Mailing List", + "Marketing Oversight Committee/Marketing Advisory Committee", + "Other", + "Product Security", + "Special Interest Group", + "Technical Advisory Committee", + "Technical Mailing List", + "Technical Oversight Committee", + "Technical Steering Committee", + "Working Group", + ) + dsl.Example("Technical Steering Committee") + }) +} + +// DescriptionAttribute is the DSL attribute for committee description. +func DescriptionAttribute() { + dsl.Attribute("description", dsl.String, "The description of the committee", func() { + dsl.MaxLength(2000) + dsl.Example("Main technical oversight committee for the project") + }) +} + +// WebsiteAttribute is the DSL attribute for committee website. +func WebsiteAttribute() { + dsl.Attribute("website", dsl.String, "The website URL of the committee", func() { + dsl.Format(dsl.FormatURI) + dsl.Pattern(`^(https?://)?[^\s/$.?#].[^\s]*$`) + dsl.Example("https://committee.example.org") + }) +} + +// EnableVotingAttribute is the DSL attribute for enabling voting. +func EnableVotingAttribute() { + dsl.Attribute("enable_voting", dsl.Boolean, "Whether voting is enabled for this committee", func() { + dsl.Default(false) + dsl.Example(true) + }) +} + +// BusinessEmailRequiredAttribute is the DSL attribute for business email requirement. +func BusinessEmailRequiredAttribute() { + dsl.Attribute("business_email_required", dsl.Boolean, "Whether business email is required for committee members", func() { + dsl.Default(false) + dsl.Example(false) + }) +} + +// SSOGroupEnabledAttribute is the DSL attribute for SSO group enablement. +func SSOGroupEnabledAttribute() { + dsl.Attribute("sso_group_enabled", dsl.Boolean, "Whether SSO group integration is enabled", func() { + dsl.Default(false) + dsl.Example(true) + }) +} + +// SSOGroupNameAttribute is the DSL attribute for SSO group name. +func SSOGroupNameAttribute() { + dsl.Attribute("sso_group_name", dsl.String, "The name of the SSO group - read-only", func() { + dsl.Example("lfx-committee-group") + }) +} + +// RequiresReviewAttribute is the DSL attribute for committee review requirement. +func RequiresReviewAttribute() { + dsl.Attribute("requires_review", dsl.Boolean, "Whether this committee is expected to be reviewed", func() { + dsl.Default(false) + dsl.Example(true) + }) +} + +// PublicAttribute is the DSL attribute for public visibility. +func PublicAttribute() { + dsl.Attribute("public", dsl.Boolean, "General committee visibility/access permissions", func() { + dsl.Default(false) + dsl.Example(true) + }) +} + +// CalendarAttribute is the DSL attribute for calendar settings. +func CalendarAttribute() { + dsl.Attribute("calendar", func() { + dsl.Description("Settings related to the committee calendar") + CalendarPublicAttribute() + }) +} + +// CalendarPublicAttribute is the DSL attribute for calendar public visibility. +func CalendarPublicAttribute() { + dsl.Attribute("public", dsl.Boolean, "Whether the committee calendar is publicly visible", func() { + dsl.Default(false) + dsl.Example(true) + }) +} + +// LastReviewedAtAttribute is the DSL attribute for last review timestamp. +func LastReviewedAtAttribute() { + dsl.Attribute("last_reviewed_at", dsl.String, "The timestamp when the committee was last reviewed in RFC3339 format", func() { + dsl.Format(dsl.FormatDateTime) + dsl.Example("2025-08-04T09:00:00Z") + }) +} + +// LastReviewedByAttribute is the DSL attribute for last review user. +func LastReviewedByAttribute() { + dsl.Attribute("last_reviewed_by", dsl.String, "The user ID who last reviewed this committee", func() { + dsl.Example("user_id_12345") + }) +} + +// DisplayNameAttribute is the DSL attribute for display name. +func DisplayNameAttribute() { + dsl.Attribute("display_name", dsl.String, "The display name of the committee", func() { + dsl.MaxLength(100) + dsl.Example("TSC Committee Calendar") + }) +} + +// ParentCommitteeUIDAttribute is the DSL attribute for parent committee UID. +func ParentCommitteeUIDAttribute() { + dsl.Attribute("parent_uid", dsl.String, "The UID of the parent committee -- v2 uid, not related to v1 id directly, should be empty if there is none", func() { + dsl.Format(dsl.FormatUUID) + dsl.Example("90b147f2-7cdd-157a-a2f4-9d4a567123fc") + }) +} + +// TotalMembersAttribute is the DSL attribute for total members count. +func TotalMembersAttribute() { + dsl.Attribute("total_members", dsl.Int, "The total number of members in this committee", func() { + dsl.Minimum(0) + dsl.Example(15) + }) +} + +// TotalVotingReposAttribute is the DSL attribute for total voting repositories count. +func TotalVotingReposAttribute() { + dsl.Attribute("total_voting_repos", dsl.Int, "The total number of repositories with voting permissions for this committee", func() { + dsl.Minimum(0) + dsl.Example(3) + }) +} + +// WritersAttribute is the DSL attribute for committee writers. +func WritersAttribute() { + dsl.Attribute("writers", dsl.ArrayOf(dsl.String), "Manager user IDs who can edit/modify this committee", func() { + dsl.Example([]string{"manager_user_id1", "manager_user_id2"}) + }) +} + +// AuditorsAttribute is the DSL attribute for committee auditors. +func AuditorsAttribute() { + dsl.Attribute("auditors", dsl.ArrayOf(dsl.String), "Auditor user IDs who can audit this committee", func() { + dsl.Example([]string{"auditor_user_id1", "auditor_user_id2"}) + }) +} + +// LastAuditedByAttribute is the DSL attribute for last audited by user. +func LastAuditedByAttribute() { + dsl.Attribute("last_audited_by", dsl.String, "The user ID who last audited the committee", func() { + dsl.Example("user_id_12345") + }) +} + +// LastAuditedTimeAttribute is the DSL attribute for last audit timestamp. +func LastAuditedTimeAttribute() { + dsl.Attribute("last_audited_time", dsl.String, "The timestamp when the committee was last audited", func() { + dsl.Format(dsl.FormatDateTime) + dsl.Example("2023-05-10T09:15:00Z") + }) +} + +// MemberVisibilityAttribute is the DSL attribute for the member visibility setting +func MemberVisibilityAttribute() { + dsl.Attribute("member_visibility", dsl.String, "Dertermines the visibility level of members profiles to other members of the same committee", func() { + dsl.Enum("hidden", "basic_profile") + dsl.Default("hidden") + dsl.Example("hidden") + }) +} + +func ShowMeetingAttendeesAttribute() { + dsl.Attribute("show_meeting_attendees", dsl.Boolean, "Determines the default show_meeting_attendees setting on meetings this committee is connected to", func() { + dsl.Default(false) + dsl.Example(false) + }) +} diff --git a/cmd/committee-api/design/type.go b/cmd/committee-api/design/type.go index 4ed5037..41f82e5 100644 --- a/cmd/committee-api/design/type.go +++ b/cmd/committee-api/design/type.go @@ -7,301 +7,7 @@ import ( "goa.design/goa/v3/dsl" ) -// CommitteeBase is the DSL type for a committee base. -var CommitteeBase = dsl.Type("committee-base", func() { - dsl.Description("A base representation of LFX committees without sub-objects.") - - CommitteeBaseAttributes() - -}) - -// CommitteeBaseAttributes is the DSL attributes for a committee base. -func CommitteeBaseAttributes() { - ProjectUIDAttribute() - NameAttribute() - CategoryAttribute() - DescriptionAttribute() - WebsiteAttribute() - EnableVotingAttribute() - SSOGroupEnabledAttribute() - RequiresReviewAttribute() - PublicAttribute() - CalendarAttribute() - DisplayNameAttribute() - ParentCommitteeUIDAttribute() -} - -// CommitteeSettings is the DSL type for a committee settings. -var CommitteeSettings = dsl.Type("committee-settings", func() { - dsl.Description("A representation of LF Committee settings.") - - CommitteeSettingsAttributes() -}) - -// CommitteeSettingsAttributes is the DSL attributes for a committee settings. -func CommitteeSettingsAttributes() { - BusinessEmailRequiredAttribute() - LastReviewedAtAttribute() - LastReviewedByAttribute() - MemberVisibilityAttribute() - ShowMeetingAttendeesAttribute() -} - -// CommitteeFull is the DSL type for a committee full. -var CommitteeFull = dsl.Type("committee-full", func() { - dsl.Description("A full representation of LFX committees with sub-objects.") - - CommitteeBaseAttributes() - - CommitteeSettingsAttributes() - - WritersAttribute() - AuditorsAttribute() -}) - -var CommitteeBaseWithReadonlyAttributes = dsl.Type("committee-base-with-readonly-attributes", func() { - dsl.Description("A base representation of LFX committees with readonly attributes.") - - CommitteeUIDAttribute() - - CommitteeBaseAttributes() - - ProjectNameAttribute() - SSOGroupNameAttribute() - - TotalMembersAttribute() - TotalVotingReposAttribute() - -}) - -var CommitteeFullWithReadonlyAttributes = dsl.Type("committee-full-with-readonly-attributes", func() { - dsl.Description("A complete representation of LFX committees with base, settings and readonly attributes.") - - CommitteeUIDAttribute() - - CommitteeBaseAttributes() - - SSOGroupNameAttribute() - - TotalMembersAttribute() - TotalVotingReposAttribute() - - // Include settings attributes for complete representation - CommitteeSettingsAttributes() - - WritersAttribute() - AuditorsAttribute() - -}) - -var CommitteeSettingsWithReadonlyAttributes = dsl.Type("committee-settings-with-readonly-attributes", func() { - dsl.Description("A representation of LF Committee settings with readonly attributes.") - - CommitteeUIDAttribute() - - CommitteeSettingsAttributes() - - CreatedAtAttribute() - UpdatedAtAttribute() - -}) - -// CommitteeUIDAttribute is the DSL attribute for committee UID. -func CommitteeUIDAttribute() { - dsl.Attribute("uid", dsl.String, "Committee UID -- v2 uid, not related to v1 id directly", func() { - // Read-only attribute - dsl.Example("7cad5a8d-19d0-41a4-81a6-043453daf9ee") - dsl.Format(dsl.FormatUUID) - }) -} - -// ProjectUIDAttribute is the DSL attribute for project UID. -func ProjectUIDAttribute() { - dsl.Attribute("project_uid", dsl.String, "Project UID this committee belongs to -- v2 uid, not related to v1 id directly", func() { - // Read-only attribute - dsl.Example("7cad5a8d-19d0-41a4-81a6-043453daf9ee") - dsl.Format(dsl.FormatUUID) - }) -} - -// ProjectNameAttribute is the DSL attribute for project name. -func ProjectNameAttribute() { - dsl.Attribute("project_name", dsl.String, "The name of the project this committee belongs to", func() { - dsl.MaxLength(100) - dsl.Example("Linux Foundation Project") - }) -} - -// NameAttribute is the DSL attribute for committee name. -func NameAttribute() { - dsl.Attribute("name", dsl.String, "The name of the committee", func() { - dsl.MaxLength(100) - dsl.Example("Technical Steering Committee") - }) -} - -// CategoryAttribute is the DSL attribute for committee category. -func CategoryAttribute() { - dsl.Attribute("category", dsl.String, "The category of the committee", func() { - dsl.Enum( - "Ambassador", - "Board", - "Code of Conduct", - "Committers", - "Expert Group", - "Finance Committee", - "Government Advisory Council", - "Legal Committee", - "Maintainers", - "Marketing Committee/Sub Committee", - "Marketing Mailing List", - "Marketing Oversight Committee/Marketing Advisory Committee", - "Other", - "Product Security", - "Special Interest Group", - "Technical Advisory Committee", - "Technical Mailing List", - "Technical Oversight Committee", - "Technical Steering Committee", - "Working Group", - ) - dsl.Example("Technical Steering Committee") - }) -} - -// DescriptionAttribute is the DSL attribute for committee description. -func DescriptionAttribute() { - dsl.Attribute("description", dsl.String, "The description of the committee", func() { - dsl.MaxLength(2000) - dsl.Example("Main technical oversight committee for the project") - }) -} - -// WebsiteAttribute is the DSL attribute for committee website. -func WebsiteAttribute() { - dsl.Attribute("website", dsl.String, "The website URL of the committee", func() { - dsl.Format(dsl.FormatURI) - dsl.Pattern(`^(https?://)?[^\s/$.?#].[^\s]*$`) - dsl.Example("https://committee.example.org") - }) -} - -// EnableVotingAttribute is the DSL attribute for enabling voting. -func EnableVotingAttribute() { - dsl.Attribute("enable_voting", dsl.Boolean, "Whether voting is enabled for this committee", func() { - dsl.Default(false) - dsl.Example(true) - }) -} - -// BusinessEmailRequiredAttribute is the DSL attribute for business email requirement. -func BusinessEmailRequiredAttribute() { - dsl.Attribute("business_email_required", dsl.Boolean, "Whether business email is required for committee members", func() { - dsl.Default(false) - dsl.Example(false) - }) -} - -// SSOGroupEnabledAttribute is the DSL attribute for SSO group enablement. -func SSOGroupEnabledAttribute() { - dsl.Attribute("sso_group_enabled", dsl.Boolean, "Whether SSO group integration is enabled", func() { - dsl.Default(false) - dsl.Example(true) - }) -} - -// SSOGroupNameAttribute is the DSL attribute for SSO group name. -func SSOGroupNameAttribute() { - dsl.Attribute("sso_group_name", dsl.String, "The name of the SSO group - read-only", func() { - dsl.Example("lfx-committee-group") - }) -} - -// RequiresReviewAttribute is the DSL attribute for committee review requirement. -func RequiresReviewAttribute() { - dsl.Attribute("requires_review", dsl.Boolean, "Whether this committee is expected to be reviewed", func() { - dsl.Default(false) - dsl.Example(true) - }) -} - -// PublicAttribute is the DSL attribute for public visibility. -func PublicAttribute() { - dsl.Attribute("public", dsl.Boolean, "General committee visibility/access permissions", func() { - dsl.Default(false) - dsl.Example(true) - }) -} - -// CalendarAttribute is the DSL attribute for calendar settings. -func CalendarAttribute() { - dsl.Attribute("calendar", func() { - dsl.Description("Settings related to the committee calendar") - CalendarPublicAttribute() - }) -} - -// CalendarPublicAttribute is the DSL attribute for calendar public visibility. -func CalendarPublicAttribute() { - dsl.Attribute("public", dsl.Boolean, "Whether the committee calendar is publicly visible", func() { - dsl.Default(false) - dsl.Example(true) - }) -} - -// LastReviewedAtAttribute is the DSL attribute for last review timestamp. -func LastReviewedAtAttribute() { - dsl.Attribute("last_reviewed_at", dsl.String, "The timestamp when the committee was last reviewed in RFC3339 format", func() { - dsl.Format(dsl.FormatDateTime) - dsl.Example("2025-08-04T09:00:00Z") - }) -} - -// LastReviewedByAttribute is the DSL attribute for last review user. -func LastReviewedByAttribute() { - dsl.Attribute("last_reviewed_by", dsl.String, "The user ID who last reviewed this committee", func() { - dsl.Example("user_id_12345") - }) -} - -// DisplayNameAttribute is the DSL attribute for display name. -func DisplayNameAttribute() { - dsl.Attribute("display_name", dsl.String, "The display name of the committee", func() { - dsl.MaxLength(100) - dsl.Example("TSC Committee Calendar") - }) -} - -// ParentCommitteeUIDAttribute is the DSL attribute for parent committee UID. -func ParentCommitteeUIDAttribute() { - dsl.Attribute("parent_uid", dsl.String, "The UID of the parent committee -- v2 uid, not related to v1 id directly, should be empty if there is none", func() { - dsl.Format(dsl.FormatUUID) - dsl.Example("90b147f2-7cdd-157a-a2f4-9d4a567123fc") - }) -} - -// TotalMembersAttribute is the DSL attribute for total members count. -func TotalMembersAttribute() { - dsl.Attribute("total_members", dsl.Int, "The total number of members in this committee", func() { - dsl.Minimum(0) - dsl.Example(15) - }) -} - -// TotalVotingReposAttribute is the DSL attribute for total voting repositories count. -func TotalVotingReposAttribute() { - dsl.Attribute("total_voting_repos", dsl.Int, "The total number of repositories with voting permissions for this committee", func() { - dsl.Minimum(0) - dsl.Example(3) - }) -} - -// WritersAttribute is the DSL attribute for committee writers. -func WritersAttribute() { - dsl.Attribute("writers", dsl.ArrayOf(dsl.String), "Manager user IDs who can edit/modify this committee", func() { - dsl.Example([]string{"manager_user_id1", "manager_user_id2"}) - }) -} +// Shared attributes used across multiple entity types. // VersionAttribute is the DSL attribute for API version. func VersionAttribute() { @@ -357,348 +63,8 @@ func UpdatedAtAttribute() { }) } -// LastAuditedByAttribute is the DSL attribute for last audited by user. -func LastAuditedByAttribute() { - dsl.Attribute("last_audited_by", dsl.String, "The user ID who last audited the committee", func() { - dsl.Example("user_id_12345") - }) -} - -// LastAuditedTimeAttribute is the DSL attribute for last audit timestamp. -func LastAuditedTimeAttribute() { - dsl.Attribute("last_audited_time", dsl.String, "The timestamp when the committee was last audited", func() { - dsl.Format(dsl.FormatDateTime) - dsl.Example("2023-05-10T09:15:00Z") - }) -} - -// AuditorsAttribute is the DSL attribute for committee auditors. -func AuditorsAttribute() { - dsl.Attribute("auditors", dsl.ArrayOf(dsl.String), "Auditor user IDs who can audit this committee", func() { - dsl.Example([]string{"auditor_user_id1", "auditor_user_id2"}) - }) -} - -// Committee Member Types and Attributes - -// CommitteeMemberBase is the DSL type for a committee member base. -var CommitteeMemberBase = dsl.Type("committee-member-base", func() { - dsl.Description("A base representation of committee members.") - - CommitteeMemberBaseAttributes() -}) - -// CommitteeMemberBaseAttributes defines the base attributes for a committee member. -func CommitteeMemberBaseAttributes() { - UsernameAttribute() - EmailAttribute() - FirstNameAttribute() - LastNameAttribute() - JobTitleAttribute() - LinkedInProfileAttribute() - RoleInfoAttributes() - AppointedByAttribute() - StatusAttribute() - VotingInfoAttributes() - OrganizationInfoAttributes() -} - -// CommitteeMemberFull is the DSL type for a complete committee member. -var CommitteeMemberFull = dsl.Type("committee-member-full", func() { - dsl.Description("A complete representation of committee members with all attributes.") - - CommitteeMemberBaseAttributes() -}) - -// CommitteeMemberFullWithReadonlyAttributes is the DSL type for a complete committee member with readonly attributes. -var CommitteeMemberFullWithReadonlyAttributes = dsl.Type("committee-member-full-with-readonly-attributes", func() { - dsl.Description("A complete representation of committee members with readonly attributes.") - - CommitteeMemberUIDAttribute() - CommitteeUIDMemberAttribute() - CommitteeNameMemberAttribute() - CommitteeCategoryMemberAttribute() - CommitteeMemberBaseAttributes() - CreatedAtAttribute() - UpdatedAtAttribute() -}) - -// CommitteeMemberCreateAttributes defines attributes for creating a committee member. -func CommitteeMemberCreateAttributes() { - CommitteeMemberBaseAttributes() -} - -// CommitteeMemberUpdateAttributes defines attributes for updating a committee member. -func CommitteeMemberUpdateAttributes() { - CommitteeMemberBaseAttributes() -} - -// Organization Information Attributes -func OrganizationInfoAttributes() { - dsl.Attribute("organization", func() { - dsl.Description("Organization information for the committee member") - OrganizationIDAttribute() - OrganizationNameAttribute() - OrganizationWebsiteAttribute() - }) -} - -// Role Information Attributes -func RoleInfoAttributes() { - dsl.Attribute("role", func() { - dsl.Description("Committee role information") - RoleNameAttribute() - RoleStartDateAttribute() - RoleEndDateAttribute() - }) -} - -// Voting Information Attributes -func VotingInfoAttributes() { - dsl.Attribute("voting", func() { - dsl.Description("Voting information for the committee member") - VotingStatusAttribute() - VotingStartDateAttribute() - VotingEndDateAttribute() - }) -} - -// Committee Member Specific Attributes - -// CommitteeMemberUIDAttribute is the DSL attribute for committee member UID. -func CommitteeMemberUIDAttribute() { - dsl.Attribute("uid", dsl.String, "Committee member UID -- v2 uid, not related to v1 id directly", func() { - dsl.Example("2200b646-fbb2-4de7-ad80-fd195a874baf") - dsl.Format(dsl.FormatUUID) - }) -} - -// CommitteeUIDAttribute is the DSL attribute for committee UID. -func CommitteeUIDMemberAttribute() { - dsl.Attribute("committee_uid", dsl.String, "Committee UID -- v2 uid, not related to v1 id directly", func() { - dsl.Example("7cad5a8d-19d0-41a4-81a6-043453daf9ee") - dsl.Format(dsl.FormatUUID) - }) -} - -// CommitteeNameMemberAttribute is the DSL attribute for committee name in member context. -func CommitteeNameMemberAttribute() { - dsl.Attribute("committee_name", dsl.String, "The name of the committee this member belongs to", func() { - dsl.MaxLength(100) - dsl.Example("Technical Steering Committee") - }) -} - -// CommitteeCategoryMemberAttribute is the DSL attribute for committee category in member context. -func CommitteeCategoryMemberAttribute() { - dsl.Attribute("committee_category", dsl.String, "The category of the committee this member belongs to", func() { - dsl.MaxLength(100) - dsl.Example("Board") - }) -} - -// MemberUIDAttribute is the DSL attribute for member UID in URL paths. -func MemberUIDAttribute() { - dsl.Attribute("member_uid", dsl.String, "Committee member UID -- v2 uid, not related to v1 id directly", func() { - dsl.Example("2200b646-fbb2-4de7-ad80-fd195a874baf") - dsl.Format(dsl.FormatUUID) - }) -} - -// UsernameAttribute is the DSL attribute for username. -func UsernameAttribute() { - dsl.Attribute("username", dsl.String, "User's LF ID", func() { - dsl.MaxLength(100) - dsl.Example("user123") - }) -} - -// EmailAttribute is the DSL attribute for email. -func EmailAttribute() { - dsl.Attribute("email", dsl.String, "Primary email address", func() { - dsl.Format(dsl.FormatEmail) - dsl.Example("user@example.com") - }) -} - -// FirstNameAttribute is the DSL attribute for first name. -func FirstNameAttribute() { - dsl.Attribute("first_name", dsl.String, "First name", func() { - dsl.MaxLength(100) - dsl.Example("John") - }) -} - -// LastNameAttribute is the DSL attribute for last name. -func LastNameAttribute() { - dsl.Attribute("last_name", dsl.String, "Last name", func() { - dsl.MaxLength(100) - dsl.Example("Doe") - }) -} - -// JobTitleAttribute is the DSL attribute for job title. -func JobTitleAttribute() { - dsl.Attribute("job_title", dsl.String, "Job title at organization", func() { - dsl.MaxLength(200) - dsl.Example("Chief Technology Officer") - }) -} - -// LinkedInProfileAttribute is the DSL attribute for LinkedIn profile URL. -func LinkedInProfileAttribute() { - dsl.Attribute("linkedin_profile", dsl.String, "LinkedIn profile URL", func() { - dsl.Format(dsl.FormatURI) - dsl.Pattern(`^(https?://)?([a-z]{2,3}\.)?linkedin\.com/.*$`) - dsl.Example("https://www.linkedin.com/in/johndoe") - }) -} - -// RoleNameAttribute is the DSL attribute for committee role name. -func RoleNameAttribute() { - dsl.Attribute("name", dsl.String, "Committee role name", func() { - dsl.Enum( - "Chair", - "Counsel", - "Developer Seat", - "TAC/TOC Representative", - "Director", - "Lead", - "None", - "Secretary", - "Treasurer", - "Vice Chair", - "LF Staff", - ) - dsl.Default("None") - dsl.Example("Chair") - }) -} - -// RoleStartDateAttribute is the DSL attribute for role start date. -func RoleStartDateAttribute() { - dsl.Attribute("start_date", dsl.String, "Role start date", func() { - dsl.Format(dsl.FormatDate) - dsl.Example("2023-01-01") - }) -} - -// RoleEndDateAttribute is the DSL attribute for role end date. -func RoleEndDateAttribute() { - dsl.Attribute("end_date", dsl.String, "Role end date", func() { - dsl.Format(dsl.FormatDate) - dsl.Example("2024-12-31") - }) -} - -// AppointedByAttribute is the DSL attribute for appointed by. -func AppointedByAttribute() { - dsl.Attribute("appointed_by", dsl.String, "How the member was appointed", func() { - dsl.Enum( - "Community", - "Membership Entitlement", - "Vote of End User Member Class", - "Vote of TSC Committee", - "Vote of TAC Committee", - "Vote of Academic Member Class", - "Vote of Lab Member Class", - "Vote of Marketing Committee", - "Vote of Governing Board", - "Vote of General Member Class", - "Vote of End User Committee", - "Vote of TOC Committee", - "Vote of Gold Member Class", - "Vote of Silver Member Class", - "Vote of Strategic Membership Class", - "None", - ) - dsl.Default("None") - dsl.Example("Community") - }) -} - -// StatusAttribute is the DSL attribute for member status. -func StatusAttribute() { - dsl.Attribute("status", dsl.String, "Member status", func() { - dsl.Enum("Active", "Inactive") - dsl.Default("Active") - dsl.Example("Active") - }) -} - -// VotingStatusAttribute is the DSL attribute for voting status. -func VotingStatusAttribute() { - dsl.Attribute("status", dsl.String, "Voting status", func() { - dsl.Enum( - "Alternate Voting Rep", - "Observer", - "Voting Rep", - "Emeritus", - "None", - ) - dsl.Default("None") - dsl.Example("Voting Rep") - }) -} - -// VotingStartDateAttribute is the DSL attribute for voting start date. -func VotingStartDateAttribute() { - dsl.Attribute("start_date", dsl.String, "Voting start date", func() { - dsl.Format(dsl.FormatDate) - dsl.Example("2023-01-01") - }) -} - -// VotingEndDateAttribute is the DSL attribute for voting end date. -func VotingEndDateAttribute() { - dsl.Attribute("end_date", dsl.String, "Voting end date", func() { - dsl.Format(dsl.FormatDate) - dsl.Example("2024-12-31") - }) -} - -// Organization Specific Attributes - -// OrganizationNameAttribute is the DSL attribute for organization name. -func OrganizationNameAttribute() { - dsl.Attribute("name", dsl.String, "Organization name", func() { - dsl.MaxLength(200) - dsl.Example("The Linux Foundation") - }) -} - -// OrganizationWebsiteAttribute is the DSL attribute for organization website. -func OrganizationWebsiteAttribute() { - dsl.Attribute("website", dsl.String, "Organization website URL", func() { - dsl.Format(dsl.FormatURI) - dsl.Example("https://linuxfoundation.org") - }) -} - -// OrganizationIDAttribute is the DSL attribute for organization ID. -func OrganizationIDAttribute() { - dsl.Attribute("id", dsl.String, "Organization ID", func() { - dsl.Example("org-123456") - }) -} - -// MemberVisibilityAttribute is the DSL attribute for the member visibility setting -func MemberVisibilityAttribute() { - dsl.Attribute("member_visibility", dsl.String, "Dertermines the visibility level of members profiles to other members of the same committee", func() { - dsl.Enum("hidden", "basic_profile") - dsl.Default("hidden") - dsl.Example("hidden") - }) -} - -func ShowMeetingAttendeesAttribute() { - dsl.Attribute("show_meeting_attendees", dsl.Boolean, "Determines the default show_meeting_attendees setting on meetings this committee is connected to", func() { - dsl.Default(false) - dsl.Example(false) - }) -} +// Error types -// Errors // BadRequestError is the DSL type for a bad request error. var BadRequestError = dsl.Type("bad-request-error", func() { dsl.Attribute("message", dsl.String, "Error message", func() {