Skip to content

Per-user GitHub identity: file as the human, not the bot, when they have a linked GitHub account #24

Description

@chubes4

Goal

When a team member has linked their GitHub account to their Extra Chill user, Roadie writes (issues via file_feature_request, PRs via propose_code_changeapply_code_change) should be authored as that human on GitHub, not as homeboy-ci[bot]. Contributors get real attribution on their GitHub graph; reviewers can see at a glance who proposed what.

When a user has not linked GitHub (e.g. Chris Gardner), the existing bot-with-WP-attribution path remains the default. The bot path is the floor, not the ceiling.

Current state (post-#22)

So the WP side knows who's asking; the resolver side knows how to pick profiles; the missing piece is the glue that connects them.

Architecture

Three small changes across three repos:

                   Roadie tool invocation
            (file_feature_request | apply_code_change)
                          │
                          ▼
   resolver.resolve( user_id: $calling_user_id, repo: $repo )    [extrachill-roadie]
                          │
                          ▼
   selectProfile() — new branch: user_id selector            [data-machine-code]
                          │
                          ▼
   user_meta('_github_credential_profile_id')                [extrachill-users]
       → returns 'user-<wp_id>' profile slug, or null
                          │
        ┌─────────────────┴─────────────────┐
        │                                   │
   profile exists                      profile missing
   resolve THAT                        fall back to repo / default
   → authored by HUMAN                 → authored by homeboy-ci[bot]

The fallback chain (user_idrepo → default) means callers always get some credential, and behavior gracefully degrades from "perfect per-user attribution" to "bot with attribution footer" depending on what's stored.

Work breakdown

Sub-task 1 — data-machine-code: add user_id selector to GitHubCredentialResolver

Where: inc/Support/GitHubCredentialResolver.php, selectProfile() method.

Diff sketch:

private static function selectProfile( ?array $selector ): array|\WP_Error {
    $profiles = self::profiles();

    if ( is_array( $selector ) ) {
        // NEW: user_id selector
        $user_id = isset( $selector['user_id'] ) ? (int) $selector['user_id'] : 0;
        if ( $user_id > 0 ) {
            $user_profile_id = (string) get_user_meta( $user_id, '_github_credential_profile_id', true );
            if ( '' !== $user_profile_id ) {
                foreach ( $profiles as $profile ) {
                    if ( (string) ( $profile['id'] ?? '' ) === $user_profile_id ) {
                        return $profile;
                    }
                }
                // Configured user profile id doesn't exist — fall through to repo/default.
                // Soft fail, do NOT error — degraded auth is better than no auth.
            }
        }

        // Existing profile_id and repo selectors stay as-is.
        $profile_id = isset( $selector['profile_id'] ) ? trim( (string) $selector['profile_id'] ) : '';
        if ( '' !== $profile_id ) { /* ...existing... */ }

        $repo = isset( $selector['repo'] ) ? trim( (string) $selector['repo'] ) : '';
        if ( '' !== $repo ) { /* ...existing... */ }
    }

    return self::resolveDefaultProfile( $profiles );
}

Selector precedence: user_id (when profile exists) → profile_idrepo → default.

Note on the get_user_meta() call inside the resolver: today the resolver is pure / WordPress-API-light (just PluginSettings::get). Adding get_user_meta() is a small WP coupling but it's the cleanest seam — the alternative is forcing every caller to do the user-meta lookup themselves and pass a resolved profile_id, which loses the elegance of "the resolver picks." Worth the trade.

Tests: extend the existing resolver smoke to cover:

  • user_id selector with configured profile → returns user profile.
  • user_id selector with no user meta → falls through to default (no WP_Error).
  • user_id selector with stale profile id (meta points at deleted profile) → falls through, no WP_Error.
  • user_id + repo both passed → user_id wins when configured.

Sub-task 2 — extrachill-users: per-user GitHub account linking

Two storage paths, ship the simpler one first.

v1 (lo-fi, ship first):

  • A "Link GitHub" section on the WP user profile page (/wp-admin/profile.php and the user-edit screen).
  • Single field: "GitHub PAT" — user pastes a fine-grained PAT they generated on github.com.
  • On save: validate the PAT via GET /user, capture the GitHub login + user_id, store as a per-user credential profile in github_credential_profiles:
    { id: 'user-38', label: 'Chris Gardner GitHub PAT', mode: 'pat', pat: 'ghp_...',
      allowed_repos: [] }   // empty = no scoping, profile is selectable only via user_id
    
  • Set user_meta('_github_credential_profile_id', 'user-38').
  • Provide an "Unlink" button that deletes the user meta AND removes the profile from the list.
  • CLI: wp extrachill users github-link <user_id> --pat=ghp_... and wp extrachill users github-unlink <user_id>.

v2 (proper, follow-up):

  • GitHub OAuth flow via gh_app_oauth or similar. User clicks "Connect GitHub", goes through GitHub's OAuth, comes back with a user-scoped App installation token.
  • Token refresh on expiry handled by the resolver (already supports App-style expiry with expires_at).
  • This is the right long-term answer but is enough work to deserve its own follow-up issue.

Security: PAT input fields must be password-typed, never echoed in HTML responses, written to github_credential_profiles via the existing sanitizer, and never exposed back to the user after save (re-entry required to change). Audit existing extrachill-users sensitive-field handling for the pattern to follow.

Sub-task 3 — extrachill-roadie: prefer per-user identity in tool calls

Where: inc/tools/class-file-feature-request.php and inc/tools/class-apply-code-change.php.

Change: when calling abilities that ultimately resolve through GitHubCredentialResolver, pass user_id as a selector hint. The abilities don't accept the selector directly today — they call getPat(['repo' => $repo]) internally. Two ways:

A. Extend the ability input schema (in data-machine-code/inc/Abilities/GitHubAbilities.php) to accept an optional user_id field that flows into getPat(['user_id' => $user_id, 'repo' => $repo]). Cleanest.

B. Resolve the token at the tool layer in Roadie via GitHubCredentialResolver::resolve() directly, then pass a one-off profile_id to the ability if such a path exists, or set up a transient filter override. Hackier.

Recommend A.

After the wiring lands, both Roadie tools call abilities with user_id => $acting_user_id (already resolved by resolve_acting_user_id() on ECRoadie_PlatformTool). Add automatic fallback to bot identity when the per-user resolution returns the default profile (i.e., user has no linked account).

Attribution footer behavior:

  • When user_id resolved to a user-specific profile → the GitHub author IS the user, so the WP-attribution footer becomes redundant. Suppress it via the existing extrachill_roadie_feature_request_attribution_lines filter, returning an empty array when the resolved profile_id starts with user-.
  • When user_id fell through to bot → keep the footer as today (still useful as proposer attribution).

Acceptance criteria

  • data-machine-code: GitHubCredentialResolver accepts user_id selector, falls through gracefully when no profile is configured.
  • data-machine-code: ability inputs (create-github-issue, create-github-pull-request, comment-github-issue, etc.) accept an optional user_id field that maps to the resolver selector.
  • extrachill-users: user profile screen has a "GitHub Account" section with PAT link/unlink. v1 lo-fi shape; OAuth tracked separately.
  • extrachill-users: CLI commands wp extrachill users github-link <user_id> --pat=... and github-unlink <user_id>.
  • extrachill-users: per-user PAT is stored as a github_credential_profiles entry with id user-<wp_id>, and the user_meta _github_credential_profile_id points at it.
  • extrachill-roadie: file_feature_request and apply_code_change pass user_id => $acting_user_id to GitHub ability calls.
  • extrachill-roadie: when the resolved credential is user-scoped, suppress the WP-attribution footer (the GitHub author IS the human, no need for a redundant body line).
  • Smoke: linked user files an issue → author is the user's GitHub account, no WP footer.
  • Smoke: unlinked user files an issue → author is homeboy-ci[bot], WP footer present (current behavior).
  • Smoke: PAT becomes invalid (revoked) → ability surfaces a clean error, does NOT silently fall back to bot.
  • Docs: extrachill-roadie/docs/contribute-code.md updated to document the dual-identity model; extrachill-users gets a short "Linking your GitHub" user-facing doc.

Out of scope

  • GitHub OAuth flow for linking (v2). v1 ships the PAT-paste path; OAuth gets its own issue once the PAT path proves the surface.
  • App-style per-user installation tokens (would require a separate Roadie/Extra-Chill GitHub App installed per-user). PATs are sufficient for v1.
  • Cross-org write surfaces. Per-user PATs scoped to the user's own accessible repos only; if they happen to be able to push to Extra-Chill/* that's fine, but Roadie's tool path still validates the target repo against the existing allowlist.
  • Bot fallback policy when per-user is configured but the token fails. Default behavior is "surface the failure to the user, do not silently bot-fallback" — they presumably linked the account because they want their identity, so falling back is surprising.

Notes

  • Chris Gardner (qrisg) does not have a GitHub account. He never links anything. His path stays exactly as it is today: homeboy-ci[bot] files the issue, WP footer credits him. This issue is for other contributors who DO have GitHub.
  • The work is small per-repo but crosses three repos. Land in this order to avoid breakage: resolver selector first, then extrachill-users storage, then Roadie wiring last.
  • This will need a coordinated release across the three plugins. Tag a sub-task list when implementing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions