Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
112 changes: 100 additions & 12 deletions src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\CommitParser;
use mglaman\DrupalOrg\DrupalOrg;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use mglaman\DrupalOrg\Result\Maintainer\MaintainerReleaseNotesResult;
use Symfony\Component\Process\Process;

Expand All @@ -24,8 +25,10 @@ class GetMaintainerReleaseNotesAction implements ActionInterface
5 => 'Plan',
];

public function __construct(private readonly Client $client)
{
public function __construct(
private readonly Client $client,
private readonly ?GitLabClient $gitLabClient = null,
) {
}

public function __invoke(
Expand Down Expand Up @@ -96,23 +99,72 @@ public function __invoke(
// Fetch data from Drupal.org concurrently.
$drupalOrg = new DrupalOrg($this->client->getGuzzleClient());
$nidList = array_values($nids);
$contributorsFromApi = $drupalOrg->getContributorsFromJsonApi($nidList);
$issueDetails = $drupalOrg->getIssueDetails($nidList);
$projectId = $drupalOrg->getProjectId($project);

// Projects migrated to GitLab work items report field_project_has_issue_queue
// as false. Work item iids are unique only within a project, so they must
// not be treated as global Drupal.org node IDs.
$projectEntity = $drupalOrg->getProject($project);
$projectId = $projectEntity?->nid;
$isGitLab = $projectEntity !== null && !$projectEntity->hasIssueQueue;
// The resolved machine name scopes GitLab work item URLs; fall back to the
// git-derived name when the project could not be looked up.
$machineName = ($projectEntity !== null && $projectEntity->machineName !== '')
? $projectEntity->machineName
: $project;

// Contribution records are filtered by the issue's source link: a node URL
// for legacy issues, a work item URL for GitLab projects.
$sourceLinks = [];
foreach ($nidList as $nid) {
$sourceLinks[$nid] = $isGitLab
? sprintf('https://git.drupalcode.org/project/%s/-/work_items/%s', $machineName, $nid)
: sprintf('https://www.drupal.org/node/%s', $nid);
}
$contributorsFromApi = $drupalOrg->getContributorsFromJsonApi($sourceLinks);

$issueDetails = $isGitLab ? [] : $drupalOrg->getIssueDetails($nidList);
$gitLabIssues = [];
if ($isGitLab) {
$gitLabClient = $this->gitLabClient ?? new GitLabClient();
$gitLabIssues = $gitLabClient->getIssuesByIid('project/' . $machineName, array_map('intval', $nidList));
}

// Track all contributors across commits.
$users = [];

// Map each issue ID to its resolved canonical URL.
$issueLinks = [];

// Process commits into categorized changes.
$categorizedChanges = [];
foreach ($commits as $commit) {
$nid = CommitParser::getNid($commit->title);

// Determine issue category.
// Determine issue category and link.
$issueCategoryLabel = 'Misc';
if ($nid !== null && isset($issueDetails[$nid])) {
$issueCategory = $issueDetails[$nid]->fieldIssueCategory;
$issueCategoryLabel = self::CATEGORY_MAP[$issueCategory] ?? 'Misc';
if ($nid !== null) {
if ($isGitLab) {
$issue = $gitLabIssues[(int) $nid] ?? null;
$link = sprintf('https://www.drupal.org/project/%s/issues/%s', $machineName, $nid);
if ($issue !== null) {
$issueCategoryLabel = self::categoryFromGitLabLabels($issue->labels)
?? CommitParser::categoryFromConventionalCommit($commit->title)
?? 'Misc';
if ($issue->webUrl !== '') {
$link = $issue->webUrl;
}
} else {
$issueCategoryLabel = CommitParser::categoryFromConventionalCommit($commit->title) ?? 'Misc';
}
} elseif (isset($issueDetails[$nid])) {
$issueCategory = $issueDetails[$nid]->fieldIssueCategory;
$issueCategoryLabel = self::CATEGORY_MAP[$issueCategory] ?? 'Misc';
$link = sprintf('https://www.drupal.org/node/%s', $nid);
} else {
$issueCategoryLabel = CommitParser::categoryFromConventionalCommit($commit->title) ?? 'Misc';
$link = sprintf('https://www.drupal.org/node/%s', $nid);
}
$issueLinks[$nid] = $link;
}

// Gather contributors: JSON:API first, then commit parsing, then email fallback.
Expand Down Expand Up @@ -147,21 +199,46 @@ public function __invoke(

// Fetch change records if we have a project ID.
$changeRecords = [];
if ($projectId !== null) {
if ($projectId !== null && $projectId !== '') {
$changeRecords = $drupalOrg->getChangeRecords($projectId, $ref2);
}

return new MaintainerReleaseNotesResult(
ref1: $ref1,
ref2: $ref2,
project: $project,
project: $machineName,
categorizedChanges: $categorizedChanges,
contributors: $users,
nidList: $nidList,
changeRecords: $changeRecords,
issueLinks: $issueLinks,
isGitLab: $isGitLab,
);
}

/**
* Resolve an issue category from a GitLab `category::*` label.
*
* @param string[] $labels
*/
private static function categoryFromGitLabLabels(array $labels): ?string
{
foreach ($labels as $label) {
$matches = [];
if (preg_match('/^category::(.+)$/i', $label, $matches) === 1) {
return match (strtolower(trim($matches[1]))) {
'bug' => self::CATEGORY_MAP[1],
'task' => self::CATEGORY_MAP[2],
'feature' => self::CATEGORY_MAP[3],
'support' => self::CATEGORY_MAP[4],
'plan' => self::CATEGORY_MAP[5],
default => null,
};
}
}
return null;
}

private function cleanCommitTitle(string $title): string
{
// Strip common prefixes.
Expand All @@ -180,7 +257,18 @@ private function getProjectName(string $cwd): string
return '';
}

$remoteUrl = $process->getOutput();
$remoteUrl = trim($process->getOutput());

// GitLab-hosted projects (including those on work items) push to
// git.drupalcode.org/project/{name} or, for issue forks,
// git.drupalcode.org/issue/{name}-{nid}. Machine names use no hyphens,
// so the capture stops before the issue fork's "-{nid}" suffix.
if (str_contains($remoteUrl, 'git.drupalcode.org')) {
$matches = [];
if (preg_match('#git\.drupalcode\.org[:/](?:project|issue)/([a-z0-9_]+)#', $remoteUrl, $matches) === 1) {
return $matches[1];
}
}

// Not a drupal.org project — use the directory name.
if (strpos($remoteUrl, 'drupal.org') === false) {
Expand Down
20 changes: 20 additions & 0 deletions src/Api/CommitParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,24 @@ public static function getNid(string $title): ?string
}
return null;
}

/**
* Derive an issue category from a conventional-commit prefix.
*
* The optional scope matches any character except ")" so titles like
* "feat(CLI Tool):" and "chore(Project management):" resolve correctly.
*/
public static function categoryFromConventionalCommit(string $title): ?string
{
$matches = [];
if (preg_match('/^(fix|feat|chore|docs|style|refactor|perf|test|build|ci)(?:\([^)]+\))?!?:\s/i', $title, $matches) !== 1) {
return null;
}
return match (strtolower($matches[1])) {
'fix' => 'Bug',
'feat' => 'Feature',
'chore' => 'Task',
default => null,
};
}
}
52 changes: 40 additions & 12 deletions src/Api/DrupalOrg.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use GuzzleHttp\Promise\Utils;
use mglaman\DrupalOrg\Entity\ChangeRecord;
use mglaman\DrupalOrg\Entity\IssueNode;
use mglaman\DrupalOrg\Entity\Project;

final class DrupalOrg
{
Expand Down Expand Up @@ -42,42 +43,69 @@ public function getProjectId(string $machineName): ?string
}
}

/**
* Get the project entity (nid + issue queue type) from a machine name.
*
* Reads field_project_has_issue_queue so callers can tell whether issues
* live on the legacy Drupal.org queue or on GitLab work items.
*/
public function getProject(string $machineName): ?Project
{
try {
$url = sprintf(
'https://www.drupal.org/api-d7/node.json?field_project_machine_name=%s',
urlencode($machineName)
);
$response = $this->client->request('GET', $url);
$data = \json_decode((string) $response->getBody());
if ($data === null || !isset($data->list) || count($data->list) === 0) {
return null;
}
return Project::fromStdClass($data->list[0]);
} catch (RequestException) {
return null;
} catch (\JsonException) {
return null;
}
}

/**
* Fetch contributors for multiple issues concurrently using promises.
*
* @param list<string> $nids Array of issue node IDs
* @return array<string, list<string>> Associative array mapping nid => array of contributor display names
* @param array<string, string> $sourceLinks Map of id => contribution source URI
* (a Drupal.org node URL for legacy issues, or a GitLab work item URL).
* @return array<string, list<string>> Map of id => contributor display names
*/
public function getContributorsFromJsonApi(array $nids): array
public function getContributorsFromJsonApi(array $sourceLinks): array
{
if ($nids === []) {
if ($sourceLinks === []) {
return [];
}

$contributors = [];

try {
$promises = [];
foreach ($nids as $nid) {
foreach ($sourceLinks as $id => $sourceLink) {
$url = sprintf(
'https://www.drupal.org/jsonapi/node/contribution_record?filter[field_source_link.uri]=https://www.drupal.org/node/%s&filter[field_contributors.field_credit_this_contributor]=1&include=field_contributors.field_contributor_user&fields[node--contribution_record]=field_contributors&fields[paragraph--contributor]=field_contributor_user,field_credit_this_contributor&fields[user--user]=display_name',
urlencode($nid)
'https://www.drupal.org/jsonapi/node/contribution_record?filter[field_source_link.uri]=%s&filter[field_contributors.field_credit_this_contributor]=1&include=field_contributors.field_contributor_user&fields[node--contribution_record]=field_contributors&fields[paragraph--contributor]=field_contributor_user,field_credit_this_contributor&fields[user--user]=display_name',
urlencode($sourceLink)
);
$promises[$nid] = $this->client->requestAsync('GET', $url);
$promises[$id] = $this->client->requestAsync('GET', $url);
}

$results = Utils::settle($promises)->wait();

foreach ($results as $nid => $result) {
foreach ($results as $id => $result) {
if ($result['state'] === PromiseInterface::FULFILLED) {
try {
$data = \json_decode((string) $result['value']->getBody(), false, 512, JSON_THROW_ON_ERROR);
$contributors[$nid] = $this->extractContributorsFromJsonApiResponse($data);
$contributors[$id] = $this->extractContributorsFromJsonApiResponse($data);
} catch (\JsonException) {
$contributors[$nid] = [];
$contributors[$id] = [];
}
} else {
$contributors[$nid] = [];
$contributors[$id] = [];
}
}
} catch (\Throwable) {
Expand Down
42 changes: 42 additions & 0 deletions src/Api/GitLab/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
namespace mglaman\DrupalOrg\GitLab;

use GuzzleHttp\HandlerStack;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Utils;
use GuzzleRetry\GuzzleRetryMiddleware;
use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue;
use Symfony\Component\Process\Process;

class Client
Expand Down Expand Up @@ -223,6 +226,45 @@ public function getIssues(string $projectPath, array $params = []): array
return is_array($result) ? $result : [];
}

/**
* Fetch multiple work items in one project concurrently, keyed by iid.
*
* Work item iids are unique only within a project, so this fetches each iid
* from the same project path. Failed or non-decodable responses map to null.
*
* @param int[] $iids
* @return array<int, GitLabIssue|null>
*/
public function getIssuesByIid(string $projectPath, array $iids): array
{
if ($iids === []) {
return [];
}

$path = 'projects/' . urlencode($projectPath) . '/issues/';
$issues = [];
$promises = [];
foreach ($iids as $iid) {
$promises[$iid] = $this->client->requestAsync('GET', $path . $iid);
}

$results = Utils::settle($promises)->wait();
foreach ($results as $iid => $result) {
if ($result['state'] !== PromiseInterface::FULFILLED) {
$issues[$iid] = null;
continue;
}
try {
$data = \json_decode((string) $result['value']->getBody(), false, 512, JSON_THROW_ON_ERROR);
$issues[$iid] = GitLabIssue::fromStdClass($data);
} catch (\JsonException) {
$issues[$iid] = null;
}
}

return $issues;
}

/**
* GET /projects/{id}/jobs/{job_id}/trace
*
Expand Down
5 changes: 5 additions & 0 deletions src/Api/Result/Maintainer/MaintainerReleaseNotesResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class MaintainerReleaseNotesResult implements ResultInterface
* @param array<string, int> $contributors username => commit count
* @param list<string> $nidList
* @param ChangeRecord[] $changeRecords
* @param array<string, string> $issueLinks nid => resolved issue URL
*/
public function __construct(
public readonly string $ref1,
Expand All @@ -21,6 +22,8 @@ public function __construct(
public readonly array $contributors,
public readonly array $nidList,
public readonly array $changeRecords,
public readonly array $issueLinks = [],
public readonly bool $isGitLab = false,
) {
}

Expand All @@ -37,6 +40,8 @@ public function jsonSerialize(): mixed
static fn(ChangeRecord $r) => ['title' => $r->title, 'url' => $r->url],
$this->changeRecords
),
'issue_links' => $this->issueLinks,
'is_gitlab' => $this->isGitLab,
];
}
}
Loading