Skip to content

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

@chubes4

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions