Skip to content

Commit 43e505c

Browse files
authored
Add GitHub credential profile migration (#517)
* fix: add GitHub credential profile migration * fix: format GitHub credential migration output
1 parent 754f150 commit 43e505c

4 files changed

Lines changed: 276 additions & 1 deletion

File tree

inc/Cli/Commands/GitHubCommand.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use DataMachineCode\Abilities\GitHubAbilities;
1717
use DataMachineCode\GitHub\PrReviewFlowInstaller;
1818
use DataMachineCode\GitHub\PrReviewFlowScaffold;
19+
use DataMachineCode\Support\GitHubCredentialSettingsMigration;
1920

2021
defined('ABSPATH') || exit;
2122

@@ -690,6 +691,69 @@ public function status( array $args, array $assoc_args ): void {
690691

691692
$this->format_items($repo_items, array( 'repo', 'label' ), $assoc_args);
692693
}
694+
695+
$legacy = $auth_status['legacy_migration'] ?? array();
696+
if ( ! empty($legacy['legacy_keys_present']) ) {
697+
WP_CLI::log('');
698+
WP_CLI::warning('Legacy GitHub credential settings are still present. Run `wp datamachine-code github migrate-credentials --apply` after reviewing the dry run.');
699+
}
700+
}
701+
702+
/**
703+
* Migrate legacy single GitHub credential settings into profiles.
704+
*
705+
* ## OPTIONS
706+
*
707+
* [--apply]
708+
* : Write github_credential_profiles and github_default_profile_id. Omit for dry run.
709+
*
710+
* [--force]
711+
* : Overwrite existing profile settings from legacy settings.
712+
*
713+
* [--format=<format>]
714+
* : Output format.
715+
* ---
716+
* default: table
717+
* options:
718+
* - table
719+
* - json
720+
* ---
721+
*
722+
* @subcommand migrate-credentials
723+
*/
724+
public function migrate_credentials( array $args, array $assoc_args ): void {
725+
$result = GitHubCredentialSettingsMigration::migrate( ! empty($assoc_args['apply']), ! empty($assoc_args['force']) );
726+
727+
if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) {
728+
WP_CLI::line( (string) wp_json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) );
729+
return;
730+
}
731+
732+
$items = array(
733+
array(
734+
'field' => 'Applied',
735+
'value' => ! empty($result['applied']) ? 'yes' : 'no',
736+
),
737+
array(
738+
'field' => 'Legacy keys present',
739+
'value' => ! empty($result['legacy_keys_present']) ? 'yes' : 'no',
740+
),
741+
array(
742+
'field' => 'Profiles present',
743+
'value' => ! empty($result['profiles_present']) ? 'yes' : 'no',
744+
),
745+
array(
746+
'field' => 'Message',
747+
'value' => (string) ( $result['message'] ?? '' ),
748+
),
749+
);
750+
$this->format_items($items, array( 'field', 'value' ), $assoc_args);
751+
752+
if ( ! empty($result['applied']) ) {
753+
WP_CLI::success('GitHub credential profiles migrated.');
754+
} else {
755+
WP_CLI::log('Dry run complete; pass --apply to write changes.');
756+
}
693757
}
694758

695759
/**

inc/Support/GitHubCredentialResolver.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public static function status(): array {
106106
$profile_summaries[] = self::summarizeProfile($profile);
107107
}
108108

109-
return array(
109+
$status = array(
110110
// Top-level fields preserve the historical surface for back-compat with existing CLI/status callers.
111111
'mode' => $mode,
112112
'pat_configured' => '' !== trim( (string) ( $default['pat'] ?? '' )),
@@ -121,6 +121,12 @@ public static function status(): array {
121121
'default_profile_id' => (string) $default['id'],
122122
'profiles' => $profile_summaries,
123123
);
124+
125+
if ( class_exists(GitHubCredentialSettingsMigration::class) ) {
126+
$status['legacy_migration'] = GitHubCredentialSettingsMigration::status();
127+
}
128+
129+
return $status;
124130
}
125131

126132
public static function isConfigured(): bool {
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
/**
3+
* Legacy GitHub credential settings migration.
4+
*
5+
* @package DataMachineCode\Support
6+
*/
7+
8+
namespace DataMachineCode\Support;
9+
10+
use DataMachine\Core\PluginSettings;
11+
12+
defined('ABSPATH') || exit;
13+
14+
final class GitHubCredentialSettingsMigration {
15+
16+
public const MIGRATED_OPTION = 'datamachine_code_github_legacy_credentials_migrated_v1';
17+
18+
private const LEGACY_KEYS = array(
19+
'github_pat',
20+
'github_auth_mode',
21+
'github_app_id',
22+
'github_app_installation_id',
23+
'github_app_private_key',
24+
'github_default_repo',
25+
);
26+
27+
/**
28+
* Report non-secret migration state for operators.
29+
*
30+
* @return array<string,mixed>
31+
*/
32+
public static function status(): array {
33+
$legacy_present = self::legacy_keys_present();
34+
$profiles = PluginSettings::get('github_credential_profiles', array());
35+
36+
return array(
37+
'legacy_keys_present' => ! empty($legacy_present),
38+
'legacy_keys' => $legacy_present,
39+
'profiles_present' => is_array($profiles) && ! empty($profiles),
40+
'migrated' => (bool) get_option(self::MIGRATED_OPTION, false),
41+
'removal_target' => 'Remove legacy writes and implicit synthesis after live installs report migrated=true.',
42+
);
43+
}
44+
45+
/**
46+
* Migrate the legacy single-credential shape into credential profiles.
47+
*
48+
* @return array<string,mixed>
49+
*/
50+
public static function migrate( bool $apply = false, bool $force = false ): array {
51+
$legacy_present = self::legacy_keys_present();
52+
$existing = PluginSettings::get('github_credential_profiles', array());
53+
$has_profiles = is_array($existing) && ! empty($existing);
54+
55+
if ( empty($legacy_present) ) {
56+
return array(
57+
'success' => true,
58+
'applied' => false,
59+
'legacy_keys_present' => false,
60+
'profiles_present' => $has_profiles,
61+
'message' => 'No legacy GitHub credential settings found.',
62+
);
63+
}
64+
65+
if ( $has_profiles && ! $force ) {
66+
return array(
67+
'success' => true,
68+
'applied' => false,
69+
'legacy_keys_present' => true,
70+
'legacy_keys' => $legacy_present,
71+
'profiles_present' => true,
72+
'message' => 'Credential profiles already exist; pass force to overwrite from legacy settings.',
73+
);
74+
}
75+
76+
$profile = self::legacy_profile();
77+
$preview = self::redact_profile($profile);
78+
79+
if ( ! $apply ) {
80+
return array(
81+
'success' => true,
82+
'applied' => false,
83+
'legacy_keys_present' => true,
84+
'legacy_keys' => $legacy_present,
85+
'profiles_present' => $has_profiles,
86+
'profile' => $preview,
87+
'message' => 'Dry run only; pass apply to write github_credential_profiles.',
88+
);
89+
}
90+
91+
self::write_setting('github_credential_profiles', array( $profile ));
92+
self::write_setting('github_default_profile_id', GitHubCredentialResolver::DEFAULT_PROFILE_ID);
93+
update_option(self::MIGRATED_OPTION, true, false);
94+
95+
return array(
96+
'success' => true,
97+
'applied' => true,
98+
'legacy_keys_present' => true,
99+
'legacy_keys' => $legacy_present,
100+
'profiles_present' => true,
101+
'profile' => $preview,
102+
'message' => 'Migrated legacy GitHub credential settings to github_credential_profiles.',
103+
);
104+
}
105+
106+
/**
107+
* @return array<int,string>
108+
*/
109+
private static function legacy_keys_present(): array {
110+
$present = array();
111+
foreach ( self::LEGACY_KEYS as $key ) {
112+
$value = PluginSettings::get($key, '');
113+
if ( is_string($value) && '' !== trim($value) ) {
114+
$present[] = $key;
115+
}
116+
}
117+
118+
return $present;
119+
}
120+
121+
/**
122+
* @return array<string,mixed>
123+
*/
124+
private static function legacy_profile(): array {
125+
$mode = strtolower(trim( (string) PluginSettings::get('github_auth_mode', '') ));
126+
if ( ! in_array($mode, array( 'pat', 'app' ), true) ) {
127+
$mode = 'pat';
128+
}
129+
130+
return GitHubProfileSanitizer::sanitize(
131+
array(
132+
array(
133+
'id' => GitHubCredentialResolver::DEFAULT_PROFILE_ID,
134+
'label' => 'Default',
135+
'mode' => $mode,
136+
'pat' => (string) PluginSettings::get('github_pat', ''),
137+
'app_id' => (string) PluginSettings::get('github_app_id', ''),
138+
'app_installation_id' => (string) PluginSettings::get('github_app_installation_id', ''),
139+
'app_private_key' => (string) PluginSettings::get('github_app_private_key', ''),
140+
'default_repo' => (string) PluginSettings::get('github_default_repo', ''),
141+
),
142+
)
143+
)[0];
144+
}
145+
146+
/**
147+
* @param array<string,mixed> $profile
148+
* @return array<string,mixed>
149+
*/
150+
private static function redact_profile( array $profile ): array {
151+
$profile['pat_configured'] = '' !== trim( (string) ( $profile['pat'] ?? '' ) );
152+
$profile['app_private_key_configured'] = '' !== trim( (string) ( $profile['app_private_key'] ?? '' ) );
153+
unset($profile['pat'], $profile['app_private_key']);
154+
return $profile;
155+
}
156+
157+
private static function write_setting( string $key, mixed $value ): void {
158+
$settings = get_option('datamachine_settings', array());
159+
if ( ! is_array($settings) ) {
160+
$settings = array();
161+
}
162+
$settings[ $key ] = $value;
163+
update_option('datamachine_settings', $settings, false);
164+
}
165+
}

tests/smoke-github-credential-profiles.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ function set_transient( string $key, $value, int $expiration ): bool
8181
return true;
8282
}
8383

84+
function get_option( string $key, $default = false )
85+
{
86+
if ('datamachine_settings' === $key ) {
87+
return $GLOBALS['dmc_settings'] ?? $default;
88+
}
89+
return $GLOBALS['dmc_options'][ $key ] ?? $default;
90+
}
91+
92+
function update_option( string $key, $value, $autoload = null ): bool
93+
{
94+
if ('datamachine_settings' === $key ) {
95+
$GLOBALS['dmc_settings'] = $value;
96+
return true;
97+
}
98+
$GLOBALS['dmc_options'][ $key ] = $value;
99+
return true;
100+
}
101+
84102
function wp_remote_retrieve_response_code( $response ): int
85103
{
86104
return (int) ( $response['response']['code'] ?? 0 );
@@ -108,8 +126,10 @@ function sanitize_key( $key )
108126

109127
include __DIR__ . '/../inc/Support/GitHubCredentialResolver.php';
110128
include __DIR__ . '/../inc/Support/GitHubProfileSanitizer.php';
129+
include __DIR__ . '/../inc/Support/GitHubCredentialSettingsMigration.php';
111130

112131
use DataMachineCode\Support\GitHubCredentialResolver;
132+
use DataMachineCode\Support\GitHubCredentialSettingsMigration;
113133
use DataMachineCode\Support\GitHubProfileSanitizer;
114134

115135
$failures = array();
@@ -125,6 +145,7 @@ function sanitize_key( $key )
125145
$reset = function ( array $settings = array() ): void {
126146
$GLOBALS['dmc_settings'] = $settings;
127147
$GLOBALS['dmc_transients'] = array();
148+
$GLOBALS['dmc_options'] = array();
128149
};
129150

130151
echo "GitHub credential profiles — smoke\n";
@@ -335,6 +356,25 @@ function sanitize_key( $key )
335356
$assert('sanitizer trims allowed_repos entries', $sanitized[0]['allowed_repos'] === array( 'team/marketing', 'team/sub' ));
336357
$assert('sanitizer normalizes capabilities', $sanitized[0]['capabilities'] === array( 'pull_request_create', 'issues_write' ));
337358

359+
// 11. Legacy settings migration dry-runs without leaking secrets, then applies profiles.
360+
$reset(
361+
array(
362+
'github_pat' => 'legacy-secret-token',
363+
'github_default_repo' => 'team/legacy',
364+
)
365+
);
366+
$migration_status = GitHubCredentialSettingsMigration::status();
367+
$assert('migration status detects legacy keys', true === $migration_status['legacy_keys_present'] && in_array('github_pat', $migration_status['legacy_keys'], true));
368+
$dry_run = GitHubCredentialSettingsMigration::migrate(false);
369+
$dry_json = wp_json_encode($dry_run);
370+
$assert('migration dry run does not expose PAT body', is_string($dry_json) && ! str_contains($dry_json, 'legacy-secret-token'));
371+
$assert('migration dry run reports redacted PAT configured flag', true === ( $dry_run['profile']['pat_configured'] ?? false ));
372+
$applied = GitHubCredentialSettingsMigration::migrate(true);
373+
$assert('migration apply writes profiles', true === ( $applied['applied'] ?? false ) && isset($GLOBALS['dmc_settings']['github_credential_profiles'][0]));
374+
$migrated = GitHubCredentialResolver::resolve();
375+
$assert('resolver uses migrated profile after apply', ! is_wp_error($migrated) && 'legacy-secret-token' === $migrated['token']);
376+
$assert('migration apply marks migrated option', true === ( $GLOBALS['dmc_options'][GitHubCredentialSettingsMigration::MIGRATED_OPTION] ?? false ));
377+
338378
if ($failures ) {
339379
echo "\nFailures:\n";
340380
foreach ( $failures as $failure ) {

0 commit comments

Comments
 (0)