feat(mentorship-sync): implement mentorship-sync CronJob with fixture source for DEV#141
Conversation
There was a problem hiding this comment.
Pull request overview
Implements the backend pieces needed to run a new mentorship-sync CronJob that sources Mentorship program data from Snowflake (or a JSON fixture in DEV) and upserts mentorship initiatives plus beneficiary rows into the Crowdfunding Postgres database.
Changes:
- Added a Snowflake client and a DEV-only JSON fixture source behind a shared
FetchProgramsshape. - Added mentorship domain models and a
MentorshipRepositorycontract + Postgres implementation. - Added the
mentorship-synccommand (syncer, entrypoint), fixture data, and a dedicated Dockerfile.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/superpowers/plans/2026-06-12-mentorship-sync.md | Implementation plan and task checklist for the mentorship-sync workstream. |
| backend/internal/infrastructure/snowflake/fixture_source.go | JSON fixture-backed Snowflake source for DEV/local runs. |
| backend/internal/infrastructure/snowflake/fixture_source_test.go | Unit tests for fixture source parsing and missing-file behavior. |
| backend/internal/infrastructure/snowflake/client.go | Snowflake client for fetching mentorship programs via SQL. |
| backend/internal/infrastructure/snowflake/client_test.go | Mock-driver unit test asserting query/table reference and scan mapping. |
| backend/internal/infrastructure/db/mentorship_repository.go | Postgres repository for program upsert + beneficiary replacement. |
| backend/internal/infrastructure/db/mentorship_repository_test.go | Compile-time interface conformance test for mentorship repository. |
| backend/internal/domain/repository.go | Adds MentorshipRepository interface to domain contracts. |
| backend/internal/domain/models/mentorship_sync.go | Adds mentorship sync domain models (MentorshipProgram, MentorshipBeneficiary). |
| backend/go.mod | Adds Snowflake driver dependency graph to module metadata. |
| backend/go.sum | Adds checksums for new dependencies (Snowflake driver and transitive deps). |
| backend/Dockerfile.mentorship-sync | Builds a standalone mentorship-sync container image and embeds fixture data. |
| backend/cmd/mentorship-sync/testdata/programs.json | DEV fixture dataset (active/pending/hidden) including beneficiaries. |
| backend/cmd/mentorship-sync/syncer.go | Sync orchestration logic (fetch → upsert program → upsert beneficiaries). |
| backend/cmd/mentorship-sync/syncer_test.go | Unit tests for syncer behavior (counts, status normalization, error handling). |
| backend/cmd/mentorship-sync/main.go | CronJob entrypoint wiring DB pool + source selection via env vars. |
| backend/cmd/mentorship-sync/helpers_test.go | Test helper logger for syncer tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- db/migrations: add migration 002 — UNIQUE constraint on initiatives.jobspring_project_id, required for ON CONFLICT upsert (per copilot-pull-request-reviewer) - snowflake/client.go: remove duplicate blank import of gosnowflake (per copilot-pull-request-reviewer) - snowflake/client.go: set Authenticator: gosnowflake.AuthTypeJwt — SDK defaults to password auth; JWT must be explicit for key-pair auth (per copilot-pull-request-reviewer) - mentorship_sync.go + syncer.go: skip UpsertBeneficiaries when Beneficiaries is nil (source did not provide data) to prevent unconditional delete of existing rows; non-nil empty slice still triggers delete as intended. Added two tests covering both cases (per copilot-pull-request-reviewer) - go.mod: bump github.com/apache/thrift 0.22.0 → 0.23.0 to fix CVE-2026-41602 (HIGH, integer overflow in TFramedTransport); mark gosnowflake as direct dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
Review Feedback AddressedCommit: 8b23ee4 Changes Made
Declined
Threads Resolved
|
Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
…ory interface Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
Implement FixtureSource to read mentorship programs from a JSON fixture file. Enables testing mentorship-sync logic without Snowflake credentials. All tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
Implements the Snowflake Client that connects using key-pair authentication and queries the ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS table. Includes unit test with mock driver to verify SQL and field mapping without requiring live Snowflake credentials. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
…iciary replacement Implements domain.MentorshipRepository against PostgreSQL with three methods: - UpsertProgram: Creates/updates mentorship initiatives by jobspring_project_id - UpsertBeneficiaries: Replaces beneficiary lists in a transaction - ListJobspringIDs: Returns all mentorship program identifiers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
Adds syncer that orchestrates the mentorship-sync run cycle (fetch programs, normalize status, upsert to DB, and log per-run counters) plus main entry point for K8s CronJob deployment. Includes comprehensive test coverage for normal flow, error handling, and status normalization. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
…o prevent silent data loss Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
…OGRAMS schema The original query used column names that do not exist in ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS, which would have caused a SQL compilation error on the first real run. Changes: - Fix column names: jobspring_project_id→PROGRAM_ID, name→PROGRAM_NAME, status→PROGRAM_STATUS - Remove mentee_goal_cents — no such column exists in the Snowflake view - Remove MenteeGoalCents from MentorshipProgram model - Normalise status with strings.ToLower instead of a hard-coded "hide" check — real values are Title-case (Published, Pending, Hidden, Rejected) - Update all tests and fixture data to use real column names, real PROGRAM_ID UUIDs, and real Title-case status values Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
Adds a dedicated mentorship-sync CronJob to the Helm chart: - templates/cronjob-mentorship-sync.yaml — CronJob resource that runs /app/mentorship-sync daily at 02:00 UTC from the dedicated image ghcr.io/linuxfoundation/lfx-crowdfunding-mentorship-sync - values.yaml — mentorshipSyncCronJob block with schedule, image, resources, and fixtureFile (injected as MENTORSHIP_SYNC_FIXTURE_FILE when set, enabling fixture-based DEV runs without Snowflake creds) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
- db/migrations: add migration 002 — UNIQUE constraint on initiatives.jobspring_project_id, required for ON CONFLICT upsert (per copilot-pull-request-reviewer) - snowflake/client.go: remove duplicate blank import of gosnowflake (per copilot-pull-request-reviewer) - snowflake/client.go: set Authenticator: gosnowflake.AuthTypeJwt — SDK defaults to password auth; JWT must be explicit for key-pair auth (per copilot-pull-request-reviewer) - mentorship_sync.go + syncer.go: skip UpsertBeneficiaries when Beneficiaries is nil (source did not provide data) to prevent unconditional delete of existing rows; non-nil empty slice still triggers delete as intended. Added two tests covering both cases (per copilot-pull-request-reviewer) - go.mod: bump github.com/apache/thrift 0.22.0 → 0.23.0 to fix CVE-2026-41602 (HIGH, integer overflow in TFramedTransport); mark gosnowflake as direct dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
- fixture_source.go: preserve nil Beneficiaries when JSON field is absent
— make([]T, 0) always returns non-nil, breaking nil=skip semantics;
now only allocates when r.Beneficiaries != nil (per copilot-pull-request-reviewer)
- syncer.go: add explicit "hide" → "hidden" normalisation after ToLower
— strings.ToLower("hide") == "hide", not "hidden"; legacy Jobspring
value requires a dedicated remap step (per copilot-pull-request-reviewer)
- syncer_test.go: add "hide" → "hidden" case to normalisation table test
(per copilot-pull-request-reviewer)
- testdata/programs.json: replace real mentee names/emails with
anonymised example.com addresses; add a "hide" status entry to
exercise the legacy normalisation path (per copilot-pull-request-reviewer)
- fixture_source_test.go: add test asserting nil Beneficiaries when
"beneficiaries" JSON key is absent (per copilot-pull-request-reviewer)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Michal Lehotsky <mlehotsky@linuxfoundation.org>
d423a4b to
09cd611
Compare
Review Feedback Addressed (round 2)Commit: 09cd611 Changes Made
Threads Resolved5 of 5 unresolved threads addressed. |
|
No action needed from this PR. |
Without this, a row that already exists with a NULL or stale jobspring_project_id (e.g. from a manual insert or earlier schema version) would never be corrected. Adding EXCLUDED.jobspring_project_id keeps the column consistent and makes the UNIQUE constraint meaningful. Signed-off-by: lewisojile <lewisojile@gmail.com>
When no ?status query param is supplied, GET /v1/initiatives and GET /v1/me/initiatives now filter by status=published by default. Callers can still override by passing ?status=<value> explicitly. Signed-off-by: lewisojile <lewisojile@gmail.com>
…nabled Previously, omitting mentorshipSyncCronJob.image.tag silently fell back to the main API image.tag, which would pull from the wrong image repository (lfx-crowdfunding-mentorship-sync vs lfx-crowdfunding-backend). Add a validate.yaml guard that aborts helm install/upgrade when the CronJob is enabled but its image tag is unset. Remove the fallback default from the CronJob template so the tag is used directly. Signed-off-by: lewisojile <lewisojile@gmail.com>
TrimSpace was only used in the guard condition, so a whitespace-padded username would pass the check but be persisted with surrounding whitespace, potentially creating stub users.username entries or causing FK lookup issues. Assign the trimmed value back to p.OwnerLFUsername before the empty check so the cleaned value flows through to UpsertProgram. Signed-off-by: lewisojile <lewisojile@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: lewisojile <lewisojile@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: lewisojile <lewisojile@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: lewisojile <lewisojile@gmail.com>
Signed-off-by: lewisojile <lewisojile@gmail.com>
There was a problem hiding this comment.
⚠️ Not ready to approve
The syncer currently normalizes Snowflake “Rejected” to “rejected”, which is not a supported initiative status in this codebase and should be mapped to an existing status (e.g. “declined”) to avoid persisting unexpected values.
Copilot's findings
- Files reviewed: 23/24 changed files
- Comments generated: 2
Note
Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.
Pull OWNER_EMAIL, OWNER_FIRST_NAME, OWNER_LAST_NAME, OWNER_AVATAR_URL from ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS and persist them into the owner's users row on each sync run. - Add OwnerEmail/FirstName/LastName/AvatarURL to MentorshipProgram model - Extend fetchProgramsQuery with 4 COALESCE'd owner columns - Update FetchPrograms scan + struct build in snowflake client - Update UpsertProgram to upsert owner profile fields using COALESCE(NULLIF(EXCLUDED.col,''), existing) so empty values never overwrite richer data already present (e.g. from Auth0) - Update fixtureProgram + FetchPrograms conversion in FixtureSource - Add owner profile fields to testdata/programs.json fixture - Update mock Columns()/rows in client_test.go; add field assertions Signed-off-by: lewisojile <lewisojile@gmail.com>
Summary
Implements the
mentorship-syncK8s CronJob that pulls Mentorship program data fromANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMSin Snowflake and upsertsinitiative_type = mentorshiprows into CF Postgres.What's included:
snowflake.Client— connects via RSA key-pair JWT auth, selectsPROGRAM_ID,PROGRAM_NAME,PROGRAM_STATUS,OWNER_LF_USERNAMEfrom the gold modelsnowflake.FixtureSource— reads a JSON file for DEV/local testing (no Snowflake credentials needed)mentorshipSourceinterface — both implementations satisfy it; selected byMENTORSHIP_SYNC_FIXTURE_FILEenv varMentorshipRepository— upserts initiative rows, resolvesowner_idby upserting a stub user row keyed onOWNER_LF_USERNAME(satisfiesNOT NULL FK), atomic beneficiary replacement (delete-then-insert in a transaction)002— addsUNIQUE (jobspring_project_id)constraint required forON CONFLICTupsertDockerfile.mentorship-syncwith testdata embedded for DEV fixture usementorshipSyncCronJob) in the backend chartci-backend.ymlandci-release.ymlupdated to build and pushghcr.io/linuxfoundation/lfx-crowdfunding-mentorship-syncCompanion PRs:
lfx-v2-argocd#959— enables the CronJob in dev/staging/prodlfx-secrets-management#273— syncsSNOWFLAKE_PRIVATE_KEYto AWS Secrets ManagerAdditional changes (post-review)
Default
status=publishedon initiative list endpoints (behaviour change — intentional)GET /v1/initiativesandGET /v1/me/initiativesnow default tostatus=publishedwhen the?statusquery param is omitted. Previously an absent param meant no status filter, so all statuses were returned. Callers can still request other statuses explicitly (e.g.?status=submitted). This aligns the API with the expected public-facing behaviour where unapproved initiatives should not be visible by default.Helm chart:
mentorshipSyncCronJob.image.tagis now required when enabledPreviously, omitting
mentorshipSyncCronJob.image.tagsilently fell back to the main APIimage.tag, which would pull from the wrong image repository. Avalidate.yamlguard now abortshelm install/upgradewith a clear error when the CronJob is enabled but its dedicated image tag is unset.Syncer:
OwnerLFUsernameis trimmed before validation and upsertstrings.TrimSpacewas previously only used in the guard condition; the untrimmed value was passed toUpsertProgram. The trimmed value is now assigned back top.OwnerLFUsernameso whitespace-padded usernames from Snowflake are cleaned before being persisted.Context
OWNER_LF_USERNAMEis sourced fromdata:lfidin the Jobspring bronze model (added inlf-dbt#2504). On each sync, a stubusersrow is created viaINSERT ... ON CONFLICT (username) DO NOTHINGto satisfy theinitiatives.owner_id NOT NULL FKwithout touching existing user profiles.Status values from Snowflake are Title-case (
Published,Hidden,Pending,Rejected). The syncer lowercases all values and additionally maps the legacy Jobspringhide→hidden.Beneficiaries (
SELECTED_MENTEES) are not yet fetched — the query will be extended in a follow-up once the gold model exposes them as structured columns.UpsertBeneficiariesis skipped whenBeneficiariesisnil(Snowflake path) to avoid deleting existing rows.Test Plan
cd backend && make test— all packages passgo build ./cmd/mentorship-sync/...— binary compilesMENTORSHIP_SYNC_FIXTURE_FILE— syncer completes withtotal=3 upserted=3 errors=0ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS) —total=1485 upserted=1485 errors=0in ~18s002applies cleanly —UNIQUEconstraint createdstatus = hiddennormalised from SnowflakeHiddenand legacyhideGET /v1/initiativeswithout?statusreturns only published initiativeshelm templatewithmentorshipSyncCronJob.enabled=trueand noimage.tagaborts with a clear error🤖 Generated with Claude Code