Skip to content

Commit 35bb069

Browse files
committed
Add NEWS parser
1 parent bc1c8a6 commit 35bb069

File tree

5 files changed

+262
-14
lines changed

5 files changed

+262
-14
lines changed

src/CommitFormatter.php

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ class CommitFormatter {
77

88
private array $commitsGroupedByAuthor = [];
99

10-
private array $nameReplacements = [];
11-
private const EOL = "\r\n";
10+
private array $nameReplacements;
11+
12+
use FormatterHelpers;
1213

1314
public function __construct(array $inputCommits, array $nameReplacements = []) {
1415
$this->process($inputCommits);
@@ -135,16 +136,4 @@ public function getFormattedCommitListGroupedByAuthorMarkup(): string {
135136

136137
return $output;
137138
}
138-
139-
private static function markdownTitle(string $title): string {
140-
return '### ' . self::plainText($title) . static::EOL;
141-
}
142-
143-
private static function markdownListItem(string $listItem): string {
144-
return ' - ' . self::plainText($listItem) . static::EOL;
145-
}
146-
147-
private static function plainText(string $text): string {
148-
return htmlspecialchars($text);
149-
}
150139
}

src/FormatterHelpers.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace PHPWatch\PHPCommitBuilder;
4+
5+
trait FormatterHelpers {
6+
private const EOL = "\r\n";
7+
8+
private static function markdownTitle(string $title): string {
9+
return '### ' . self::plainText($title) . static::EOL;
10+
}
11+
12+
private static function markdownListItem(string $listItem): string {
13+
return ' - ' . self::plainText($listItem) . static::EOL;
14+
}
15+
16+
private static function plainText(string $text): string {
17+
return htmlspecialchars($text);
18+
}
19+
}

src/KeywordEnhancer.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ private static function linkToSecurityAnnouncements(string $inputText): string {
8181
);
8282
}
8383

84+
if (preg_match('/(GHSA-[a-z\d-]{14})(\W)/', $inputText)) {
85+
$inputText = preg_replace(
86+
'/(GHSA-[a-z\d-]{14})(\W)/',
87+
"[$1](https://github.com/php/php-src/security/advisories/$1)$2",
88+
$inputText
89+
);
90+
}
91+
8492
return $inputText;
8593
}
8694

src/NewsFetcher.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
namespace PHPWatch\PHPCommitBuilder;
4+
use Ayesh\CurlFetcher\CurlFetcher;
5+
6+
class NewsFetcher {
7+
private const RAW_CONTENT_URL = 'https://raw.githubusercontent.com/php/php-src/%tag/NEWS';
8+
9+
private const REGEX_PIPE_HEADER = '/^\|+$/';
10+
private const REGEX_RELEASE_HEADER = '/^(?<date>(?<day>\d\d|\?\?) (?<month>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|\?\?\?) (?<year>\?\?\?\?|20\d\d)), PHP (?<release_id>\d\.\d\.\d\d?)$/';
11+
private const REGEX_EXT_HEADER = '/^- (?<ext_name>[A-Za-z][A-Za-z _\/\d]+):?$/';
12+
13+
private const REGEX_CHANGE_RECORD_START = '/^ \. (?<change_record>.*)$/';
14+
private const REGEX_CHANGE_RECORD_CONTINUATION = '/^ (?<change_record_cont>.*)$/';
15+
16+
private ?string $apiKey = null;
17+
private CurlFetcher $curlFetcher;
18+
19+
public function __construct(string $apiKey = null) {
20+
$this->apiKey = $apiKey;
21+
$this->curlFetcher = new CurlFetcher();
22+
}
23+
24+
public function fetchAllForVersion(int $version): array {
25+
preg_match('/^(?<major>^\d)0(?<minor>\d)\d\d?$/', (string) $version, $matches);
26+
27+
if (empty($matches)) {
28+
throw new \InvalidArgumentException('Invalid $version: Must be in integer "XYYZZ" format');
29+
}
30+
31+
$baseUrl = strtr(static::RAW_CONTENT_URL, [
32+
'%tag' => "PHP-{$matches['major']}.{$matches['minor']}",
33+
]);
34+
35+
$headers = [];
36+
if ($this->apiKey) {
37+
$headers[] = 'Authorization: Bearer '. $this->apiKey;
38+
}
39+
40+
$contents = $this->curlFetcher->get($baseUrl, $headers);
41+
42+
return $this->parseNewsPage($contents);
43+
}
44+
45+
private function parseNewsPage(string $contents): array {
46+
$contents = preg_split('/\R/', $contents);
47+
48+
$releases = [];
49+
$accumulatedChanges = [];
50+
$cursorVersion = null;
51+
$cursorExt = null;
52+
$lastLine = null;
53+
54+
foreach ($contents as $lineNo => $line) {
55+
$lineNo = (int) $lineNo;
56+
++$lineNo; // Line numbers start from 1, although the array index starts at 0
57+
58+
59+
// Should skip line?
60+
if ($this->skipLine($line, $lineNo)) {
61+
continue;
62+
}
63+
64+
// Is this a new release header?
65+
if ($release = $this->releaseHeader($line)) {
66+
$releases[$release['version']] = $release;
67+
68+
if ($cursorVersion) {
69+
$release[$cursorVersion]['changes'] = $accumulatedChanges;
70+
}
71+
$cursorVersion = $release['version'];
72+
$cursorExt = null;
73+
$lastLine = null;
74+
75+
76+
continue;
77+
}
78+
79+
if (empty($cursorVersion)) {
80+
throw new \RuntimeException('Cursor version should not be empty when detecting a new extension change set');
81+
}
82+
83+
// Is this a new ext changeset header?
84+
if ($extName = $this->extHeader($line)) {
85+
$cursorExt = $extName;
86+
$lastLine = null;
87+
continue;
88+
}
89+
90+
if (empty($cursorExt)) {
91+
throw new \RuntimeException('Cursor ext name should not be empty when detecting a new change');
92+
}
93+
94+
// is this new change record?
95+
if ($changeRecord = $this->changeRecord($line)) {
96+
$lastLine = $lineNo;
97+
$releases[$cursorVersion]['changes'][$cursorExt][$lastLine] = $changeRecord;
98+
continue;
99+
}
100+
101+
if ($lastLine === null) {
102+
throw new \RuntimeException('Cursor last line number should not be empty when detecting a continuation of a change record');
103+
}
104+
105+
// is this continuation of a line?
106+
if ($changeRecordCont = $this->lineWrapped($line)) {
107+
$releases[$cursorVersion]['changes'][$cursorExt][$lastLine] .= ' ' . $changeRecordCont;
108+
continue;
109+
}
110+
111+
throw new \Exception(\sprintf("Unknown line format at line %d:\r\n%s", $lineNo, $line));
112+
}
113+
114+
return $releases;
115+
}
116+
117+
private function skipLine(mixed $line, int $lineNo): bool {
118+
if ($line === '' || trim($line) === '') {
119+
return true;
120+
}
121+
122+
if ($lineNo === 1 && str_starts_with($line, 'PHP ')) {
123+
return true;
124+
}
125+
126+
if (preg_match(self::REGEX_PIPE_HEADER, $line)) {
127+
return true;
128+
}
129+
130+
return false;
131+
}
132+
133+
private function releaseHeader(string $line): array|false {
134+
if (preg_match(self::REGEX_RELEASE_HEADER, $line, $matches)) {
135+
return [
136+
'version' => $matches['release_id'],
137+
'date' => str_contains($matches['date'], '?') ? null : "{$matches['year']} {$matches['month']} {$matches['day']}",
138+
'changes' => [],
139+
];
140+
}
141+
142+
return false;
143+
}
144+
145+
private function extHeader(string $line): string|false {
146+
if (preg_match(self::REGEX_EXT_HEADER, $line, $matches)) {
147+
return $matches['ext_name'];
148+
}
149+
150+
return false;
151+
}
152+
153+
private function changeRecord(string $line): string|false {
154+
if (preg_match(self::REGEX_CHANGE_RECORD_START, $line, $matches)) {
155+
return $matches['change_record'];
156+
}
157+
158+
return false;
159+
}
160+
161+
private function lineWrapped(string $line): string|false {
162+
if (preg_match(self::REGEX_CHANGE_RECORD_CONTINUATION, $line, $matches)) {
163+
return $matches['change_record_cont'];
164+
}
165+
166+
return false;
167+
}
168+
}

src/NewsFormatter.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace PHPWatch\PHPCommitBuilder;
4+
class NewsFormatter {
5+
private array $releases;
6+
7+
use FormatterHelpers;
8+
public function __construct(array $releases) {
9+
$this->releases = $releases;
10+
}
11+
12+
public function getNewsListForRelease(string $version): array {
13+
if (!isset($this->releases[$version])) {
14+
throw new \InvalidArgumentException('Given release not found in the releases set');
15+
}
16+
17+
if (!isset($this->releases[$version]['version'], $this->releases[$version]['changes'])) {
18+
throw new \RuntimeException('Parsed array structure is invalid. Does not contain both version and changes fields');
19+
}
20+
21+
if (empty($this->releases[$version]['changes'])) {
22+
throw new \RuntimeException('Parsed array structure is invalid. Changes list is empty');
23+
}
24+
25+
$version = $this->releases[$version];
26+
27+
foreach ($version['changes'] as $ext => &$changes) {
28+
foreach ($changes as &$change) {
29+
$change = $this->removeAuthorInBraces($change);
30+
$change = KeywordEnhancer::enhance($change);
31+
}
32+
}
33+
34+
return $version;
35+
}
36+
37+
38+
public function getNewsListForReleaseMarkup(string $version): string {
39+
$release = $this->getNewsListForRelease($version);
40+
41+
$output = '';
42+
43+
foreach ($release['changes'] as $ext => $changes) {
44+
$output .= self::markdownTitle($ext);
45+
46+
foreach ($changes as $change) {
47+
$output .= self::markdownListItem($change);
48+
}
49+
50+
$output .= self::EOL . self::EOL;
51+
}
52+
53+
return $output;
54+
}
55+
56+
private function removeAuthorInBraces(string $change): string {
57+
$change = preg_replace('/^(.*) \([\w ,]+\)$/', '$1', $change, 1, $count);
58+
if ($count !== 1) {
59+
throw new \RuntimeException('Failed to remove author braces in: '. $change);
60+
}
61+
62+
return $change;
63+
}
64+
}

0 commit comments

Comments
 (0)