Skip to content
Open

#2709 #1894

Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"access/drupal_seamless_cilogon": "3.0.x-dev",
"access/infrastructure_news": "1.0.x-dev",
"access/operations_cider": "dev-main",
"amp/access": "3.0.x-dev",
"amp/access": "d8-2709-dev",
"composer/installers": "^2.3.0",
"cweagans/composer-patches": "^2.0",
"drupal/access_by_ref": "^4.0.0",
Expand Down Expand Up @@ -201,7 +201,7 @@
"drupal_fork/cilogon_auth": "main-dev",
"drupal_fork/domain_adv": "main-dev",
"drush/drush": "^13.6.2",
"necyberteam/asp-theme": "main-dev",
"necyberteam/asp-theme": "d8-2709-dev",
"necyberteam/campus-champions-theme": "main-dev",
"necyberteam/webform_submission_search_api": "main-dev",
"npm-asset/select2": "^4.0",
Expand Down
24 changes: 11 additions & 13 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

File renamed without changes.
220 changes: 201 additions & 19 deletions robo/src/Robo/Plugin/Commands/GhCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,41 +166,223 @@ public function pullfiles() {
* @description Create a pull request.
*/
public function ghpr() {
$branch = shell_exec("git rev-parse --abbrev-ref HEAD");
$branch = preg_replace('/\r\n|\r|\n/', '', $branch);
$full_branch = shell_exec("git rev-parse --abbrev-ref HEAD");
$full_branch = preg_replace('/\r\n|\r|\n/', '', $full_branch);

$target_branch = $branch == 'md-dev' ? 'main' : 'md-dev';
$target_branch = $full_branch == 'md-dev' ? 'main' : 'md-dev';

$branch = explode("-", $branch);

$issue_number = $branch[1];
$branch_parts = explode("-", $full_branch);
$issue_number = $branch_parts[1];
$issue_number = preg_replace('/\r\n|\r|\n/', '', $issue_number);
Comment on lines +174 to 176
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

$issue_number = $branch_parts[1]; assumes the current branch name always contains a - with the issue number in the second segment. If the command is run from a branch like md-dev, main, or any non-standard branch name, this will produce an undefined offset and/or an incorrect issue number (e.g., md-dev -> dev). Consider extracting the issue number with a regex (e.g., first \d+ match) and failing fast with a clear message when it can't be determined.

Suggested change
$branch_parts = explode("-", $full_branch);
$issue_number = $branch_parts[1];
$issue_number = preg_replace('/\r\n|\r|\n/', '', $issue_number);
if (!preg_match('/\d+/', $full_branch, $matches)) {
throw new TaskException(
$this,
"Unable to determine issue number from branch '$full_branch'. Use a branch name containing the issue number."
);
}
$issue_number = $matches[0];

Copilot uses AI. Check for mistakes.

$ask_description = $this->ask("Describe context / purpose for this PR");
$description = $this->getDescription($issue_number);
$custom_repos = $this->findCustomModuleRepos($issue_number);

$body = $this->buildPrBody($description, $issue_number, '-');

// Copy template to clipboard (macOS only).
if (strtoupper(substr(PHP_OS, 0, 3)) === 'DAR') {
$escaped = escapeshellarg($body);
shell_exec("echo $escaped | pbcopy");
$this->say("PR description template copied to clipboard!");
}

$this->say("Creating PR for D8-$issue_number");

$template = "## Describe context / purpose for this PR
$ask_description
$pr_urls = [];

// Create main repo PR first.
$main_pr_url = $this->createPr(NULL, NULL, $target_branch, "#$issue_number", $body);
if ($main_pr_url) {
$pr_urls[] = $main_pr_url;
}

// Create PRs for pinned custom module repos.
foreach ($custom_repos as $repo_info) {
$this->say("Creating PR for {$repo_info['package']} ({$repo_info['ghRepo']})...");

$default_branch = trim(shell_exec(
"gh repo view " . escapeshellarg($repo_info['ghRepo']) .
" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null"
) ?? '');

if ($default_branch === '') {
$this->say("⚠️ Could not detect default branch for {$repo_info['ghRepo']}, skipping.");
continue;
}

$pr_url = $this->createPr($repo_info['ghRepo'], $repo_info['branch'], $default_branch, "#$issue_number", $body);
if ($pr_url) {
$pr_urls[] = $pr_url;
}
}

// Cross-reference all created PRs.
if (!empty($pr_urls)) {
$this->crossReferencePrs($pr_urls, $description, $issue_number);
}

// Print summary.
$this->say("\n✅ Created PRs:");
foreach ($pr_urls as $url) {
$this->say(" - $url");
}
}

/**
* Fetch PR description from Jira via acli, or fall back to interactive prompt.
*/
private function getDescription(string $issueNumber): string {
$acli_path = trim(shell_exec("which acli 2>/dev/null") ?? '');
if ($acli_path !== '') {
$output = shell_exec("acli jira workitem view d8-$issueNumber --fields summary 2>/dev/null") ?? '';
if ($output !== '') {
// Try JSON parse first.
$decoded = json_decode(trim($output), TRUE);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && isset($decoded['summary'])) {
return trim($decoded['summary']);
}
// Fall back to regex extraction.
if (preg_match('/summary[:\s]+(.+)/i', $output, $matches)) {
$summary = trim($matches[1]);
if ($summary !== '') {
return $summary;
}
}
}
}
return $this->ask("Describe context / purpose for this PR");
}

/**
* Find custom module repos pinned to this issue number in composer.json.
*
* @return array<array{package: string, ghRepo: string, branch: string}>
*/
private function findCustomModuleRepos(string $issueNumber): array {
$composer_path = getcwd() . '/composer.json';
if (!file_exists($composer_path)) {
return [];
}

$composer = json_decode(file_get_contents($composer_path), TRUE);
if (!$composer) {
return [];
}

$repositories = $composer['repositories'] ?? [];
$packages = array_merge(
$composer['require'] ?? [],
$composer['require-dev'] ?? []
);

$results = [];
foreach ($packages as $package => $version) {
if (strpos($version, $issueNumber) === FALSE) {
continue;
}

// Extract the suffix (part after /).
$suffix = str_contains($package, '/') ? substr(strstr($package, '/'), 1) : $package;

// Find matching repository entry by key.
$repo_url = NULL;
foreach ($repositories as $key => $repo) {
if ($key === $suffix && isset($repo['url'])) {
$repo_url = $repo['url'];
break;
}
}
Comment thread
protitude marked this conversation as resolved.

if ($repo_url === NULL) {
continue;
}

// Extract owner/repo from GitHub URL.
if (!preg_match('#github\.com[/:](.+?)(?:\.git)?$#', $repo_url, $m)) {
continue;
}
$gh_repo = $m[1];

// Strip -dev suffix to get the feature branch name.
$branch = preg_replace('/-dev$/', '', $version);

$results[] = [
'package' => $package,
'ghRepo' => $gh_repo,
'branch' => $branch,
];
}

return $results;
}

/**
* Build the shared PR body template.
*/
private function buildPrBody(string $description, string $issueNumber, string $prList): string {
return "## Describe context / purpose for this PR
$description
## Issue link
https://cyberteamportal.atlassian.net/browse/D8-$issue_number
https://cyberteamportal.atlassian.net/browse/D8-$issueNumber
## Any other related PRs?
-
$prList
## Link to MultiDev instance
http://md-$issue_number-accessmatch.pantheonsite.io
http://md-$issueNumber-accessmatch.pantheonsite.io

## Checklist for PR author
- [ ] I have checked that the PR is ready to be merged
- [ ] I have reviewed the DIFF and checked that the changes are as expected
- [ ] I have assigned myself or someone else to review the PR";
}

$this->say("Creating PR for D8-$issue_number");
/**
* Create a PR and return its URL, or null on failure.
*
* @param string|null $repo GitHub owner/repo for --repo flag; null for current repo.
* @param string|null $head Head branch; only used when $repo is provided.
*/
private function createPr(?string $repo, ?string $head, string $base, string $title, string $body): ?string {
$cmd = "gh pr create"
. " --title " . escapeshellarg($title)
. " --body " . escapeshellarg($body)
. " --base " . escapeshellarg($base);

if ($repo !== NULL) {
$cmd .= " --repo " . escapeshellarg($repo);
$cmd .= " --head " . escapeshellarg($head);
}

// Copy template to clipboard for easy pasting into PR description.
// MacOS only.
if (strtoupper(substr(PHP_OS, 0, 3)) === 'DAR') {
$this->_exec("echo '$template' | pbcopy");
$this->say("PR description template copied to clipboard!");
// Capture stdout (the PR URL); stderr goes directly to terminal.
$output_lines = [];
$return_code = 0;
exec($cmd, $output_lines, $return_code);

foreach (array_reverse($output_lines) as $line) {
$line = trim($line);
if (str_starts_with($line, 'https://')) {
return $line;
}
}

if ($return_code !== 0 || empty($output_lines)) {
$this->say("⚠️ Could not create PR or parse URL from output.");
}

return NULL;
}

/**
* Update all PRs to cross-reference each other in the related PRs section.
*/
private function crossReferencePrs(array $prUrls, string $description, string $issueNumber): void {
$pr_list = implode("\n", array_map(fn(string $url) => "- $url", $prUrls));
$body = $this->buildPrBody($description, $issueNumber, $pr_list);
$escaped_body = escapeshellarg($body);

foreach ($prUrls as $pr_url) {
$this->_exec("gh pr edit " . escapeshellarg($pr_url) . " --body $escaped_body");
}
$this->_exec("gh pr create --title '#$issue_number' --body '$template' --base $target_branch");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
uuid: e44296cc-bba5-46c8-9997-1370d6060c67
langcode: en
status: true
dependencies:
config:
- node.type.tools_case_study
id: node.tools_case_study.promote
field_name: promote
entity_type: node
bundle: tools_case_study
label: 'Promoted to front page'
description: ''
required: false
translatable: true
default_value:
-
value: 0
default_value_callback: ''
settings:
on_label: 'On'
off_label: 'Off'
field_type: boolean
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
uuid: 9fc1983c-e8c2-44dc-8703-86958a2406b3
langcode: en
status: true
dependencies:
config:
- node.type.tools_case_study
id: node.tools_case_study.title
field_name: title
entity_type: node
bundle: tools_case_study
label: 'Headline Title'
description: ''
required: true
translatable: true
default_value: { }
default_value_callback: ''
settings: { }
field_type: string
Loading
Loading