{"id":51778331,"url":"https://github.com/bborbe/recurring-task-creator","last_synced_at":"2026-07-20T08:32:14.711Z","repository":{"id":365516411,"uuid":"1262129746","full_name":"bborbe/recurring-task-creator","owner":"bborbe","description":"Recurring Kafka task event publisher driven by Kubernetes CRDs.","archived":false,"fork":false,"pushed_at":"2026-07-19T19:16:15.000Z","size":671,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-20T08:32:12.717Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"bsd-2-clause","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bborbe.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-07T16:01:10.000Z","updated_at":"2026-07-19T19:16:18.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bborbe/recurring-task-creator","commit_stats":null,"previous_names":["bborbe/recurring-task-creator"],"tags_count":17,"template":false,"template_full_name":null,"purl":"pkg:github/bborbe/recurring-task-creator","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bborbe%2Frecurring-task-creator","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bborbe%2Frecurring-task-creator/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bborbe%2Frecurring-task-creator/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bborbe%2Frecurring-task-creator/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bborbe","download_url":"https://codeload.github.com/bborbe/recurring-task-creator/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bborbe%2Frecurring-task-creator/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35681006,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"ssl_error","status_checked_at":"2026-07-20T02:08:09.736Z","response_time":111,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-20T08:32:14.060Z","updated_at":"2026-07-20T08:32:14.699Z","avatar_url":"https://github.com/bborbe.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Recurring Task Creator\n\n[![Go Reference](https://pkg.go.dev/badge/github.com/bborbe/recurring-task-creator.svg)](https://pkg.go.dev/github.com/bborbe/recurring-task-creator)\n[![CI](https://github.com/bborbe/recurring-task-creator/actions/workflows/ci.yml/badge.svg)](https://github.com/bborbe/recurring-task-creator/actions/workflows/ci.yml)\n[![Go Report Card](https://goreportcard.com/badge/github.com/bborbe/recurring-task-creator)](https://goreportcard.com/report/github.com/bborbe/recurring-task-creator)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bborbe/recurring-task-creator)\n\nPublishes `task.CreateCommand` events to Kafka on a fixed schedule so a downstream task-controller materializes recurring tasks (daily / weekly / weekday / monthly / quarterly / yearly) as Obsidian vault `.md` files.\n\nTask definitions live as Kubernetes `Schedule` Custom Resources — adding / editing / removing a recurring task is `kubectl apply`, not a code release.\n\n## How it works\n\n```\nkubectl apply -f schedule.yaml          ← operator\n        │\n        ▼\n   K8s API server  (Schedule CRD, group task.benjamin-borbe.de/v1)\n        │ watch\n        ▼\n   recurring-task-creator pod  ← installs the CRD at boot,\n                                 runs an informer in its own pod\n                                 namespace, hourly tick reads from\n                                 the cache\n        │ task.CreateCommand\n        ▼\n   Kafka  ← topic per vault (e.g. task-command-personal)\n        │\n        ▼\n   agent-task-controller pod  ← consumes the topic,\n                                writes the markdown file\n        │\n        ▼\n   Obsidian vault git remote  ← committed by the controller\n```\n\nIdentifiers are deterministic: `UUID5(\"recurring-\u003cslug\u003e-\u003cperiod-token\u003e\")`, where the period token is `YYYY-MM-DD` (daily), `YYYYWww` (weekly), `YYYYWww-\u003c3-letter-weekday\u003e` (weekday), `YYYY-MM` (monthly), `YYYYQN` (quarterly), `YYYY` (yearly). The downstream controller dedups on identifier → safe to re-publish every tick, safe to manual `/trigger?date=YYYY-MM-DD` replay, safe to crash-restart.\n\n## Define a schedule\n\nSee `k8s/apis/task.benjamin-borbe.de/v1/testdata/example.yaml` for the canonical shape:\n\n```yaml\napiVersion: task.benjamin-borbe.de/v1\nkind: Schedule\nmetadata:\n  name: weekly-review              # frozen — UUID5 input\n  namespace: default               # the recurring-task-creator pod watches its own namespace\nspec:\n  vault: default                   # routes to agent-task-controller-\u003cvault\u003e\n  title: Weekly Review {{current_week}}   # placeholder-rendered; period token also suffixed\n  schedule:\n    recurrence: Weekday            # Daily | Weekly | Weekday | Monthly | Quarterly | Yearly\n    weekday: Saturday              # required iff recurrence == Weekday (Monday..Sunday)\n    periodOffset: 0                # optional, default 0; shifts period-anchored token by N periods (Monthly/Quarterly/Yearly only). Use -1 for \"review the just-completed period\" schedules.\n  template:\n    body: |\n      Reflect on the past week ({{current_week}}).\n      Plan the next ({{next_week}}).\n    frontmatter:                   # YAML frontmatter stamped onto the generated task file\n      priority: 2\n      status: in_progress\n      page_type: task\n      assignee: bborbe\n      planned_date: \"{{current_date}}\"   # placeholder-rendered in string values\n      due_date: \"{{next_sun_date}}\"\n```\n\nThe CRD's OpenAPI schema enforces the recurrence enum, the weekday enum, and two CEL rules: `weekday` is required iff `recurrence == \"Weekday\"`, and non-zero `periodOffset` is allowed only for `Monthly` / `Quarterly` / `Yearly`.\n\n### periodOffset\n\n`spec.schedule.periodOffset` (int, default 0) shifts the period-anchored token by N periods. The shift applies to both the title suffix and the UUID5 identifier input, so re-publishing the same `(slug, fire-date, offset)` triple stays idempotent. Body placeholders (`{{current_month}}`, `{{last_month}}`, etc.) are NOT affected — they always render against the unshifted fire date.\n\nThe motivating case is review-style schedules. A `monthly-review` schedule with `periodOffset: -1` is always-fire (fires every day), but every tick names the prior month: on `2026-07-01` the publisher emits identifier UUID5(...) for token `2026-06`, materializing the task `Review Month - 2026-06.md`. The same identifier is re-published every day in July (dedups), then rolls to `2026-07` on `2026-08-01`. The body's `{{last_month}}` placeholder still renders the correct prior month relative to the fire date — title and content agree.\n\nQuarterly uses `±3 months`; yearly uses `±N years`. Negative values name prior periods; positive values name future periods. Cross-year/quarter boundaries are handled by Go's `time.AddDate` semantics.\n\n## Template placeholders\n\nSubstituted in `title`, `body`, and any **string-valued** `frontmatter` field. Non-string frontmatter values (ints, slices, maps) pass through unchanged. Closed set — unknown tokens like `{{foo}}` render verbatim. All values are computed against the Berlin civil date the task fires for.\n\n| Placeholder | Renders | Example (Sat 2026-06-20) |\n|---|---|---|\n| `{{current_date}}` | `YYYY-MM-DD` | `2026-06-20` |\n| `{{next_sat_date}}` | `YYYY-MM-DD` (today if today IS Sat) | `2026-06-20` |\n| `{{next_sun_date}}` | `YYYY-MM-DD` (today if today IS Sun) | `2026-06-21` |\n| `{{current_week}}` | `YYYYWNN` (ISO) | `2026W25` |\n| `{{next_week}}` | `YYYYWNN +7d` | `2026W26` |\n| `{{current_month}}` | `YYYY-MM` | `2026-06` |\n| `{{next_month}}` | `YYYY-MM +1mo` | `2026-07` |\n| `{{last_month}}` | `YYYY-MM −1mo` | `2026-05` |\n| `{{current_quarter}}` | `YYYYQN` | `2026Q2` |\n| `{{last_quarter}}` | `YYYYQN −1q` | `2026Q1` |\n| `{{current_year}}` | `YYYY` | `2026` |\n| `{{next_year}}` | `YYYY +1y` | `2027` |\n| `{{last_year}}` | `YYYY −1y` | `2025` |\n\n`{{next_sat_date}}` / `{{next_sun_date}}` use **inclusive-today** semantics so a Sunday Schedule firing on Sun stamps `planned_date=\u003ctoday\u003e`, not `\u003ctoday+7\u003e`.\n\n\u003e **Removed in v0.3.0:** the pre-v0.2.0 kebab-case alias names (`{{date}}`, `{{iso-week}}`, `{{next-iso-week}}`, `{{month}}`, `{{last-month}}`, `{{quarter}}`, `{{last-quarter}}`, `{{year}}`, `{{last-year}}`) no longer render. Use the canonical snake_case names above.\n\n## Build + deploy\n\n```bash\nmake test          # ginkgo specs + coverage\nmake buca          # build + push docker.io/bborbe/recurring-task-creator:$(VERSION) (publish-only)\nmake helm-publish  # package + push the Helm chart to oci://registry-1.docker.io/bborbe\n```\n\n`make buca` is **publish-only** (build image + push); it applies nothing to a cluster. Deployment is the Helm chart in [`helm/`](helm/) — a `StatefulSet`, `Service`, `Role` + `RoleBinding` (namespace-scoped `Schedule` watch), `ClusterRole` + `ClusterRoleBinding` (boot-time CRD self-install), and optional Strimzi `KafkaUser` + Sentry `Secret`. Per-cluster config (Kafka brokers, stage, `topicPrefix`, Sentry, node affinity, pull secrets) is supplied by the consuming values — the quant deployment lives in the private `bborbe/quant` config repo.\n\n\u003e **`topicPrefix` must match the downstream controller.** The service publishes to `\u003ctopicPrefix\u003e-agent-task-v1-request` (empty = unprefixed). Set `topicPrefix` to whatever the agent-task-controller consuming the topic uses (e.g. `develop` / `master`), or the tasks land on a topic nobody reads.\n\n`Schedule` CRs themselves live in a separate (typically private) repository — they reference the deployed image but are operator-owned data, not part of the binary. The repo deployer applies them with `kubectl apply -k \u003coverlay\u003e` (kustomize) or any GitOps mechanism of choice.\n\n## RBAC\n\nThe pod's ServiceAccount needs:\n\n- `apiextensions.k8s.io/customresourcedefinitions` `get / create / update / patch` at cluster scope — for the boot-time CRD install\n- `task.benjamin-borbe.de/schedules` `get / list / watch` in the pod's own namespace — for the informer\n\nManifests: [`helm/templates/rbac.yaml`](helm/templates/rbac.yaml) (ServiceAccount + Role/RoleBinding + ClusterRole/ClusterRoleBinding). The ClusterRole is namespace-suffixed so dev + prod installs don't collide on the cluster-scoped object.\n\n## Local smoke-test\n\n```bash\nDRY_RUN=true ./recurring-task-creator-run-once -logtostderr -v=2\n```\n\nSkips Kafka init entirely (uses a noop sender); logs every `(slug, date, identifier)` triple the publisher *would* send. Use to verify a Schedule's behavior before deploying.\n\n## Repo layout\n\n| Path | Purpose |\n|---|---|\n| `main.go` + `cmd/run-once/main.go` | Long-lived service entry point + one-tick smoke binary |\n| `pkg/k8s_connector.go` | Self-installing CRD (`SetupCustomResourceDefinition`) — get-or-create-or-update on every binary boot, 30s timeout |\n| `pkg/k8s_connector_schema.go` | The Go-built `JSONSchemaProps` (vault regex, recurrence enum, weekday enum, CEL rule) |\n| `pkg/store` | Informer-backed `ScheduleStore` — the only source of truth at tick time |\n| `pkg/publisher` | Builds `task.CreateCommand`, computes the period-anchored UUID5, sends via injected sender |\n| `pkg/tick` | Hourly cron loop + Prometheus metrics |\n| `pkg/handler` | HTTP — `/healthz`, `/trigger?date=YYYY-MM-DD`, `/setloglevel/{n}`, `/metrics` |\n| `pkg/factory` | Composition root — `Create*` constructors that wire everything |\n| `pkg/schedule` | Internal types — `Date`, `RecurrenceKind`, `TaskDefinition`, `TasksForDate` |\n| `k8s/apis/task.benjamin-borbe.de/v1/` | CRD Go types (hand-written) + `zz_generated.deepcopy.go` |\n| `k8s/client/` | Generated typed clientset + informers + listers + applyconfiguration |\n| `helm/` | Standalone Helm deploy chart (STS + RBAC + Service + optional KafkaUser/Secret) |\n| `hack/update-codegen.sh` | Wraps `kube_codegen.sh` for the client tree |\n| `mocks/` | Counterfeiter mocks |\n| `specs/`, `prompts/` | dark-factory development history (specs → prompts → daemon-executed code) |\n\n## License\n\nBSD-2-Clause. See `LICENSE`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbborbe%2Frecurring-task-creator","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbborbe%2Frecurring-task-creator","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbborbe%2Frecurring-task-creator/lists"}