Skip to content

fix(tasks): attach the github installation that reaches the task repository - #119056

Closed
Silthus wants to merge 5 commits into
PostHog:masterfrom
Silthus:fix/tasks-github-installation-for-repository
Closed

Silthus wants to merge 5 commits into
PostHog:masterfrom
Silthus:fix/tasks-github-installation-for-repository

Conversation

@Silthus

@Silthus Silthus commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • A team with more than one GitHub installation can get a task whose installation cannot reach the task's repository. The run then fails at clone time instead of at creation.
  • Two code paths pick the team's installation by different rules:
    • The connected-repository check reads the repositories of one installation, picked by account type and age (agent.py, list_team_connected_repositories).
    • Task creation attaches the first installation the query returns, with no repository check (Task._build_task). The tasks API and the AI create-task tool do the same.
  • Every create_and_run_task caller that passes a repository is affected. Two examples: "Run with PostHog Code" in feat(workflows): request a change to a code-managed email template through PostHog Code #119032 and the experiment cleanup PR.

Warning

🟡 Merge risk: medium · two-way door (merge-risk rubric)

  • Task creation now picks the GitHub credential for a repository write by repository. When no installation reaches the repository, create_and_run_task fails at creation, including for a team with one installation.
  • About 180 changed production lines. A revert fully undoes the change. Tests cover each branch of the rule, and a base-versus-HEAD run shows every changed outcome.

Changes

  • Task creation now attaches the first usable team installation whose repositories include every private repository of the task.
  • The repository cache answers first, and it refreshes only when a caller allows it.
    • An installation that never synced its cache comes next, because its access is unknown, not missing.
    • Only then does GitHub confirm the miss: creation re-syncs every working installation once (throttled to once a minute), including one whose cache synced empty. A cache that predates a new repository therefore does not fail creation.
    • An installation whose sync fails counts as unknown and is attached, as before. One that the sync reports as uninstalled is skipped.
  • create_and_run_task fails at creation when GitHub confirms that no installation reaches the repository. The error is "No GitHub installation of team … can access …". A team without a working installation still gets "does not have a GitHub integration".
  • Bot-authored tasks fall back to the creator's personal GitHub only when the team has no working installation, as before. A miss does not move a bot task onto personal credentials.
  • The tasks API and the AI create-task tool attach the reaching installation too. When none reaches, they keep the old first-installation fallback, so their error responses do not change.
  • The connected-repository list now covers every usable installation, not only the first.
  • Public sandbox repositories never decide the pick on any path. Repository-less tasks keep the first working installation.
  • Mechanical: one shared query for usable installations and their order, usable_team_github_integrations, lives in the light github_repository_access.py. resolve_team_github_integration uses it with no change in behavior.

How did you test this code?

Test rationale:

  • Test seams: Task.create_and_run (behind create_and_run_task), the POST /api/projects/:id/tasks/ endpoint, CreateTaskTool, and list_team_connected_repositories. The only mocked boundary is the GitHub repository listing.
  • test_create_and_run_picks_the_installation_that_reaches_the_repository covers the multi-installation pick, the unsynced fallback, and one installation that reaches every repository of a multi-repository task. It fails on base.
  • test_create_and_run_non_public_repo_without_a_reaching_installation_raises (renamed and parameterized) covers no installation, no reaching installation, and installations that each reach only some repositories.
  • test_create_and_run_confirms_a_cache_miss_with_github catches a stale or empty cache failing creation. It covers GitHub listing the repository and GitHub failing.
  • test_create_and_run_skips_an_installation_github_reports_gone catches attaching an installation the re-sync found uninstalled.
  • test_create_and_run_bot_task_keeps_off_personal_github_when_the_team_has_an_installation catches a bot task moving onto a member's credentials.
  • test_create_task_attaches_the_installation_that_reaches_the_repository (API) and its CreateTaskTool twin catch the first-installation pick on those paths. The API test also checks that a public sandbox repository makes no GitHub call.
  • test_reports_repositories_from_every_installation catches a list that reads only the first installation. The mocked test_archived_repositories_are_excluded is gone, because the DB-backed test next to it covers the same archived and unflagged repositories.
  • The neighboring suites also ran green: test_api.py (full), test_models.py, test_max_tools.py, test_warm_facade.py, test_facade.py, the experiment cleanup PR tests, wizard run creation, and the signals auto-start, report, and installation-resolution tests.

Shadow diff (live run on the local dev database). A script called create_and_run_task(start_workflow=False) and list_team_connected_repositories in a Django shell, on base cdf8d66 and on HEAD. Each case ran in a rolled-back transaction with invented acme/* repositories. The caches were seconds old, so the throttle skipped the GitHub re-sync.

Case Repository Base: connected / task gets HEAD: connected / task gets
One installation reaches the repo acme/widgets acme/widgets / install 0 same
One unsynced installation acme/widgets none / install 0 same
One installation lacks the repo acme/widgets acme/other / install 0 acme/other / creation fails
Second of two installations reaches the repo acme/widgets acme/other / install 0 both / install 1
Neither of two installations reaches the repo acme/widgets acme/other / install 0 both / creation fails
Public sandbox repo, two installations posthog/hedgebox acme/other / install 0 both / install 0
Repository-less task, two installations none acme/other / install 0 both / install 0

Every difference follows from the goal.

Proofs owed and gaps:

  • Live run: the shadow diff above drove the facade on the running dev database. Not run: a Temporal task run that clones the repository, or a re-sync against a real GitHub installation. Tests cover the re-sync with a mocked listing.
  • Prod shape: gap. Read-only production access is not available yet, so the share of teams with several installations, or with a cache that lacks the task repository, is unknown.
  • Post-deploy watch: owed after the merge.

Release status

  • No feature flag controls this change

Docs update

None. No doc under docs/ covers how a task picks its GitHub installation.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5

Question Answer Who Where
Separate PR for the selection gap Ship it on its own, outside the Workflows email work 👤 Human Side fix of the Workflows email conductor thread
Review depth qa-swarm, because credential selection is a merge-risk danger area 👤 Human Standing instruction
Source of the "reaches" check The cache first, and a GitHub re-sync only on a full miss; not a live probe per installation on every creation 🤖 Agent Changes; review ledger R1-F1
Stale cache on a miss Re-sync once before failing; treating stale caches as unknown was rejected, because it still fails for a repository created minutes ago 🤖 Agent Review ledger R1-F1
Unsynced installation A no-call fallback after installations whose cache confirms the repository 🤖 Agent Changes
API and AI-tool paths with no reaching installation Keep the first-installation fallback; failing at creation there would change the API's error responses 🤖 Agent Review ledger R1-S1
Public and repository-less tasks Public sandbox repositories never decide the pick; repository-less tasks keep the first working installation 🤖 Agent Changes
Retry throttle for failed re-syncs None added; the per-installation GitHub egress budget already bounds them 🤖 Agent Review ledger R2-S2

Involvement: no grilling rounds, no prototypes, qa-swarm rounds 1 and 2 plus a final read (GPT-6.1 Sol review lane, Opus 5.5 fresh eyes; Team lane skipped), shadow-diff proof captured.

  • Skills: tdd, writing-tests, merge-risk, qa-swarm, pr-description, writing-pr-descriptions, writing-code-comments.
  • No duplicate: an open-PR search found fix(tasks): handle repos outside the github app installation #117234. It handles repositories outside the installation when the run mints its token, not which installation creation picks.
  • Related, out of scope: the experiment cleanup PR target still reads only the first installation's repositories (experiment_service.py). It can call a repository ambiguous when a later installation reaches it.
  • Public artifact: all repository names and fixtures are invented.

🤖 Generated with Claude Code

…sitory

Task creation took the team's first GitHub installation, while the
connected-repository check read only one installation's repositories. A team
with several installations could create a task whose installation cannot
reach its repository, and the run then failed at clone time.

Both paths now share one rule: the usable team installations in one order.
Task creation attaches the first installation whose cached repositories
include every private repository of the task, and fails at creation when
none does. The connected-repository list covers every usable installation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Oct 17, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions github-actions Bot added the feature/agent-infra Feature Tag: Agent infra label Oct 17, 2026
Silthus and others added 4 commits October 17, 2026 17:55
…creation

The tasks API create path and the AI create-task tool still attached the
team's first GitHub installation. The connected-repository list now
counts every installation, so a repository only a later installation
reaches passed that check and then ran with credentials that could not
reach it.

Both paths now take the installation that reaches the task's
repositories and fall back to the first installation as before. The
model tests also pin that one installation must reach every repository
of a multi-repository task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion

The repository cache refreshes only when a caller reads it with refresh
allowed, so a cache synced before a repository existed made task
creation fail for a repository the installation reaches. Creation now
re-syncs the synced installations once when no cache confirms the
repository, and treats an installation whose sync fails as unknown.

Bot-authored tasks again fall back to the creator's personal GitHub
only when the team has no installation, and the "can access" error
appears only when a working installation exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An installation whose synced cache was empty was left out of the
re-sync on a cache miss. A team whose only installation gained its first
repository after that sync then failed task creation, where it used to
attach the installation. The miss path now re-syncs every working
installation, and task creation reads its installations through the
same shared query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion pick

A re-sync that finds an installation uninstalled marks it gone, but the
pick still attached it as unknown, so the task was created and then
failed at run time. The pick now skips it, and task creation checks for
a working installation after the pick.

Public sandbox repositories need no credential, so the shared pick now
ignores them for every caller instead of syncing every installation for
them. The AI create-task tool resolves its installation before its
transaction, so a GitHub sync never holds the transaction open. The
raising tests age their caches so the mocked GitHub listing runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus marked this pull request as ready for review October 19, 2026 08:36
@parameterai

parameterai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Risk: No findings

This change makes task creation pick the GitHub installation that actually reaches the task's private repositories (cache first, one throttled GitHub re-sync to confirm a miss), and fails creation closed with "No GitHub installation of team … can access …" when none does; the repo list now covers every usable installation. I traced every credential-selection path (models, facade API, max tool, repo-selection agent) and found no security regression: all queries stay team-scoped, the bot-mode and error-path changes are fail-closed, and the unsynced/unconfirmed fallbacks attach same-team credentials only, matching prior behavior.

Sentinel reviewed b2d95a5 · Review settings

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team October 19, 2026 08:37

@tatoalo tatoalo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some comments 🙏🏻

for integration in integrations:
if wanted <= _cached_full_names(integration):
return integration
unsynced = next((i for i in integrations if i.repository_cache_updated_at is None), None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to check the candidates that could own the repo before taking this fallback path, e.g. stale cache

}


def _refreshed_full_names(integration: Integration) -> set[str] | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipped refresh should leave access not confirmed orr trigger a targeted check rather than justify rejecting the task (and here we check against 60secs thresholds)

return unconfirmed


def _still_working(integration: Integration) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to handle transient failures from upstream gh

}


def _refreshed_full_names(integration: Integration) -> set[str] | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we honor retry-after on failed refreshes?

.exclude(config__has_key=INSTALLATION_UNAVAILABLE_SINCE_CONFIG_KEY)
.first()
)
if github_resolution_allowed and private_repositories:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move this outside the transaction, this blocks unrelated ops

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/agent-infra Feature Tag: Agent infra

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants