GitHub Integration & Automations

The Operation plugin's GitHub App integration (issue ↔ task sync) and its automation triggers and actions.

Backend code lives under backend/plugins/operation_api/src/modules/githubIntegration/, src/modules/automations/, and src/utils/ (githubClient.ts, githubWebhookHandler.ts). The UI lives at settings/operation/github in operation_ui.

Data models

  • operation_github_configs: links one team to one repo: { teamId, repoName, installationId, syncMode: 'oneWay' | 'twoWay', subdomain }. Unique indexes on { teamId, subdomain } and { repoName, subdomain }.
  • operation_github_connections: an installed GitHub App org: { installationId, orgName, orgAvatarUrl, orgType, initiatedUserId, isActive, subdomain }. Unique on { installationId, subdomain }.
  • operation_github_milestone_mappings: maps erxes milestones to GitHub milestones per repo: { subdomain, installationId, repoName, erxesMilestoneId, githubMilestoneId, githubMilestoneNumber, githubMilestoneTitle }.

Tasks and triage items carry githubIssueNumber, githubIssueUrl, githubRepoName once linked. Triage enforces a unique sparse index on { githubRepoName, githubIssueNumber }.

Setup and authentication

The integration is a GitHub App authenticated with GITHUB_APP_ID + GITHUB_PRIVATE_KEY (installation tokens via getInstallationOctokit); webhooks are verified with GITHUB_WEBHOOK_SECRET (HMAC-SHA256 over the raw body, x-hub-signature-256).

  • GET /pl:operation/integrations/github/setup: returns a small HTML page that postMessage({ type: 'github-install-complete' }) to window.opener and closes the popup. Point the GitHub App's post-install redirect here.
  • POST /pl:operation/integrations/github/webhook: GitHub webhook receiver. Returns 400 without a resolvable subdomain, 401 on missing/invalid signature, 500 when GITHUB_WEBHOOK_SECRET is unset, pong on ping.

Webhook events handled:

x-github-eventBehavior
installationcreated/unsuspend upserts an active GithubConnection; deleted/suspend deactivates it
issues openedCreates a triage item on the configured team and appends <!-- erxes-task-id: ... --> to the issue body
issues opened/reopened/closedFor an already-linked task, moves it to the team's first status of type UNSTARTED / COMPLETED / CANCELLED (closed + not_planned or duplicate → cancelled)
issues milestoned/demilestonedSets/clears the task's milestoneId via GithubMilestoneMapping; only when the config's teamId matches the task's team
anything elseignored

Bot senders (sender.type === 'Bot') are skipped to prevent sync loops.

Outbound (two-way) sync

When a team's config has syncMode: 'twoWay':

  • createTask opens a GitHub issue (POST /repos/{owner}/{repo}/issues) with a body linking back to https://<subdomain>.erxes.io/operation/team/<teamId>/tasks/<id> plus the erxes-task-id marker, then writes the issue number/URL onto the task and pushes the milestone when milestoneId is set.
  • updateTask pushes status (updateGithubIssueState maps COMPLETED → closed/completed, CANCELLED → closed/not_planned, anything else → open) and milestone changes (updateGithubIssueMilestone, which looks up or creates the GitHub milestone by title via resolveGithubMilestoneNumber).

GitHub failures are logged and swallowed; a task mutation never fails because the issue sync did. In oneWay mode only inbound (GitHub → erxes) events apply. The sync lives in the task mutations.

Queries and mutations

OperationKindPermission
getGithubConfigByTeam(teamId) / getAllGithubConfigs(installationId)queryteamRead
upsertGithubConfig(teamId, repoName, installationId, syncMode)mutationteamUpdate
operationGithubDisconnectTeam(teamId)mutationteamUpdate
getGithubConnection / getGithubConnections / getGithubRepositories(installationId)queryteamRead
disconnectGithubConnection(installationId)mutationteamUpdate

upsertGithubConfig validates the team exists, the installation has an active connection, the repo isn't already linked to another team, repoName is owner/repo form, and the repo is reachable through that installation (GET /repos/{owner}/{repo}). Re-linking a team to a different installation/repo deletes its milestone mappings; operationGithubDisconnectTeam does the same; disconnectGithubConnection refuses while any team still references the installation.

Automations

meta/automations.ts connects modules/automations/ (handlers in automationHandlers.ts, actions in actions/) to the platform automation service.

Triggers (operationAutomationConstants.triggers):

TriggerTarget typeNotes
Taskoperation:task.taskssegment enrollment
Projectoperation:project.projectssegment enrollment
Project completedoperation:project.projects (relationType: 'completed')custom; fires when every task in the project is complete
Milestone reachedoperation:project.milestones (relationType: 'reached')custom; milestone tasks hit the configured completion mode
Team work completedoperation:team.teams (relationType: 'completed')custom; team tasks hit the completion mode

The three custom triggers support completion modes every/some/first/last (checkCustomTrigger in automationHandlers.ts evaluates task status types under the selector).

Actions (actions/):

ActionModuleNotes
Create tasktaskcreateTaskAction; target source operation:task.tasks
Create projectprojectcreateProjectAction; target source operation:project.projects

setProperties supports writing fields on tasks, projects, milestones, and teams via setProperty rules and setPropertyTargets. Automation task outputs include a DOMAIN-based link (/operation/tasks/<id>).

Environment variables

VariablePurpose
GITHUB_APP_IDGitHub App id for installation tokens
GITHUB_PRIVATE_KEYApp private key (PEM)
GITHUB_WEBHOOK_SECRETWebhook signature secret; webhook 500s without it
DOMAINTask links embedded in GitHub issue bodies and automation outputs
VERSIONsaas changes org-scoped behavior (cycle worker, org flags)

Sync not working?

If opened issues do not create triage items, no GithubConfig exists for that installationId + repoName + subdomain. If status changes do not reach GitHub, outbound sync only applies to twoWay configs where githubRepoName still matches; outbound errors are logged, not thrown.

Was this helpful?