Files
TimeTracker/docs/integrations/GITHUB_CONNECTOR.md
T
Dries Peeters 3e7b7ab787 feat: add per-user GitHub, Google Calendar, and Slack connectors
Three new opt-in integration connectors that plug into the existing
`app/integrations/base.py:BaseConnector` pattern and the integrations
settings UI. Each connector subclasses `BaseConnector`, persists its
state inside the existing `Integration.config` JSONB (no new tables),
encrypts every secret at rest via `app/utils/secret_crypto`, and
degrades gracefully when the integration row is missing or
`is_active=False` -- every method returns
`{"ok": false, "error": "Integration not configured"}` without
raising, so the timer, exports, and dashboards keep working when a
connector is disabled or broken.

All third-party HTTP calls go through `requests` with a 10-second
timeout and a `try/except requests.RequestException`. Tokens are
never written to logs in their raw form -- only short
`xoxb-...` / `ghp_...` truncations.

GitHub connector (`app/integrations/github_connector.py`, provider
key `github_connector`):
  - Webhook receiver at `POST /api/integrations/github/webhook`
    verifies `X-Hub-Signature-256` with HMAC-SHA256 against the
    per-integration webhook secret before reading the payload.
  - Handles `issues.opened` (creates a task with
    `external_ref="github_issue_{n}"`, mapped priority and `todo`
    status), `issues.assigned` (optionally starts a timer for the
    linked TimeTracker user when `users.github_username` matches),
    `issues.closed` (marks the existing task `done`), and `ping`.
  - Manual sync (`POST /api/integrations/github/sync`, admin only)
    pulls open issues from
    `GET /repos/{owner}/{repo}/issues?state=open&per_page=50` and
    upserts tasks by `external_ref`. Optional `label_filter`.

Google Calendar connector (`app/integrations/google_calendar_connector.py`,
provider key `google_calendar_connector`):
  - OAuth2 flow at `/integrations/google/{connect,callback,disconnect}`
    using raw `requests` against
    `https://oauth2.googleapis.com/token`. Tokens (`access_token`,
    `refresh_token`, `token_expiry`) are stored encrypted in
    `Integration.config`. `client_id`/`client_secret` come from
    Flask config (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`) and are
    never hardcoded.
  - `_refresh_token_if_needed()` refreshes within 5 minutes of expiry
    on every API call.
  - `sync()` supports `import` / `export` / `both`:
    * import: pulls dated events from the configured `calendar_id`
      over the last `sync_days_back` days (clamped 1-30), skips
      all-day events and anything tagged `[TT]` or already linked via
      `gcal:{event_id}` in the notes of an existing `TimeEntry`.
    * export: posts completed entries created since `last_sync_at`
      back to Google as `[TT] {project} -- {task or notes}` events
      with `timeZone: "UTC"`.
  - `revoke()` calls `https://oauth2.googleapis.com/revoke` and
    wipes the stored tokens.
  - APScheduler job `google_calendar_sync` runs every 30 minutes;
    each user is wrapped in `try/except` so one broken token cannot
    block the rest.

Slack connector (`app/integrations/slack_connector.py`, provider key
`slack_connector`):
  - Webhook receiver at `POST /api/integrations/slack/events`
    verifies `X-Slack-Signature` (HMAC-SHA256 of
    `v0:{timestamp}:{body}`) and rejects requests older than 5
    minutes. Replies to Slack's URL verification handshake
    immediately.
  - Slash command `/tt` supports `start [project]` (id or
    case-insensitive partial name match against the user's allowed
    projects), `stop`, `status`, `today` (via
    `notification_service.get_today_summary_for_user`), and an
    in-place help text fallback. Every reply is ephemeral JSON so it
    fits inside Slack's 3-second budget without touching
    `response_url`.
  - `notify_timer_started` / `notify_timer_stopped` post a
    stopwatch/checkmark message to the configured channel. Wired
    into both the page route (`app/routes/timer.py`) and the JSON
    API (`app/routes/api.py`) as a fire-and-forget hook: the import
    + call are wrapped in `try/except` and only log at `DEBUG` on
    failure, so Slack outages can't slow down the timer flow.
  - `post_daily_summary` posts a daily roll-up; APScheduler job
    `slack_daily_summary` runs every 30 minutes and matches each
    user's configured `HH:MM` against the window.

Plumbing and storage:
  - New blueprint `app/routes/integrations_webhooks.py` registers
    the webhook receivers (`csrf.exempt`, signature-verified) plus a
    uniform `config`/`status`/`test` API surface
    (`/api/integrations/{github,google,slack}/{config,status,test}`)
    used by the settings UI. Optional-registered in
    `app/blueprint_registry.py`.
  - Alembic revision `155_add_integration_columns`:
    * `users.github_username` (String(100), nullable) - GitHub login
      join key for the assignment auto-start-timer flow.
    * `tasks.external_ref` (String(200), nullable, indexed) -
      canonical external id for connector-created tasks; the new
      index lets webhook receivers de-duplicate cheaply.
    Both columns are added defensively (inspector-checked) so the
    migration is safe to re-run.
  - New cards in `app/templates/integrations/_connector_cards.html`
    (included by `templates/integrations/list.html`) drive the
    Personal connectors UI -- Tailwind CSS only, vanilla JS, per-card
    status fetch, save, test, and sync actions.

Documentation:
  - `docs/integrations/README.md` indexes all built-in connectors.
  - `docs/integrations/GITHUB_CONNECTOR.md`,
    `docs/integrations/GOOGLE_CALENDAR.md`, and
    `docs/integrations/SLACK.md` cover setup, OAuth/webhook wiring,
    config fields, endpoints, and operational notes for each
    connector.
  - `docs/api/REST_API.md` lists the new endpoints under a new
    "Personal integration connectors" subsection.
  - `CHANGELOG.md` notes the feature under the [Unreleased] section.

`LLMService`, `TimeTrackingService`, `ForecastService`, and the
`Integration` model schema are intentionally untouched -- only
`users` and `tasks` gain columns via migration.
2026-05-15 10:45:30 +02:00

5.4 KiB

GitHub connector (personal, webhook-driven)

Lives in app/integrations/github_connector.py (provider key github_connector). This is not the OAuth-based connector at app/integrations/github.py (provider key github) — both can be active on the same install; pick the one that suits your workflow.

A small, per-user opt-in connector that turns a GitHub repository into a TimeTracker task feed. Uses a personal access token (no OAuth dance) and an HMAC-SHA256 webhook secret to drive task creation and (optionally) auto-start timers when an issue is assigned.

What it does

Event Action
issues / opened Creates a Task in the configured default project with external_ref = github_issue_{n}, mapped priority (see below), status="todo".
issues / assigned If auto_start_timer is on and the assignee's GitHub login matches a TimeTracker user's github_username, starts a timer for that user on the default project.
issues / closed Marks the existing task (matched by external_ref) as status="done".
ping Returns 200 {"ok": true, "message": "Webhook received"}.
Anything else 422 (unhandled event).

A manual Sync now also pulls the first 50 open issues from GET /repos/{owner}/{repo}/issues?state=open and creates any tasks that don't already exist.

Priority mapping

Labels are scanned in order and the first match wins:

GitHub label (lowercase) TimeTracker priority
bug, critical high
enhancement medium
anything else low

Configuration

Open Integrations → GitHub (the personal-connector card under Personal connectors) and fill in:

Field Stored as Required Notes
Personal access token integration.config.github_token (encrypted) Yes Needs repo scope to read issues.
Owner integration.config.repo_owner Yes e.g. octocat
Repository integration.config.repo_name Yes e.g. Hello-World
Default project integration.config.default_project_id Yes Pick a TimeTracker project.
Auto-start timer integration.config.auto_start_timer No Off by default.
Label filter integration.config.label_filter (lowercased) No If set, manual sync only imports issues that carry this label.
Webhook secret integration.config.webhook_secret (encrypted) Yes (auto-generated) Auto-filled with secrets.token_urlsafe(32) on first save.

Tokens and the webhook secret are stored encrypted at rest when SETTINGS_ENCRYPTION_KEY is configured (Fernet key, see the rest of TimeTracker's secret handling). When the key is absent the connector falls back to plain text and logs a warning.

Wiring the GitHub webhook

  1. Open the integration card and Save — this generates the webhook secret if one isn't already stored and shows the receiver URL.
  2. In GitHub, go to Repo Settings → Webhooks → Add webhook with:
    • Payload URL: {base_url}/api/integrations/github/webhook
    • Content type: application/json
    • Secret: the webhook secret from the card
    • Events: just Issues (or use Send me everything if you prefer — the connector responds 422 to anything it doesn't handle).
  3. The first delivery is a ping; the connector replies 200 with {"ok": true, "message": "Webhook received"}.

The receiver verifies X-Hub-Signature-256 with hmac.compare_digest against webhook_secret and the raw request body, so don't add a proxy that rewrites the body.

Linking a TimeTracker user to a GitHub login

users.github_username is the join key. The 155 Alembic migration adds the column; set it from Profile → Settings or by an admin updating the user row. Auto-start-timer events are skipped silently if no TimeTracker user matches the GitHub assignee.

Endpoints

Method Path Auth Purpose
POST /api/integrations/github/webhook Signature (HMAC-SHA256) Receives GitHub events.
POST /api/integrations/github/sync @login_required, admin only Manual one-shot sync.
POST /api/integrations/github/config @login_required Save the UI form.
POST /api/integrations/github/test @login_required Calls GET /user to verify the token.
GET /api/integrations/github/status @login_required Returns the current config snapshot (without secrets).

All HTTP calls to api.github.com use a 10-second timeout and are wrapped in try/except requests.RequestException.

Operational notes

  • The connector is read-only on GitHub — it never opens issues or posts comments.
  • external_ref on the tasks table is indexed; the connector de-duplicates by (project_id, external_ref) so re-deliveries of the same issues / opened event are safe.
  • Manual sync also writes integration.last_sync_at / last_sync_status, surfaced on the existing Integrations health dashboard.
  • The connector degrades gracefully: if the integration row is missing or is_active=False, every method returns {"ok": false, "error": "Integration not configured"} without raising.
  • Tokens never appear in logs — they're truncated to prefix... via an internal helper.