diff --git a/config/sql/se/Projects.sql b/config/sql/se/Projects.sql index 9fc157d2..e1374cc1 100644 --- a/config/sql/se/Projects.sql +++ b/config/sql/se/Projects.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS `Projects` ( `ProjectId` int(10) unsigned NOT NULL AUTO_INCREMENT, - `Status` enum('in_progress','stalled','completed','abandoned') NOT NULL DEFAULT 'in_progress', + `Status` enum('in_progress','awaiting_review','reviewed','stalled','completed','abandoned') NOT NULL DEFAULT 'in_progress', `EbookId` int(11) NOT NULL, `ProducerName` varchar(151) NOT NULL DEFAULT '', `ProducerEmail` varchar(80) DEFAULT NULL, diff --git a/lib/Ebook.php b/lib/Ebook.php index 4aee0a22..57a5d78b 100644 --- a/lib/Ebook.php +++ b/lib/Ebook.php @@ -185,8 +185,8 @@ final class Ebook{ inner join Ebooks on Projects.EbookId = Ebooks.EbookId where Ebooks.EbookId = ? - and Status in (?, ?) - ', [$this->EbookId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled], Project::class)[0] ?? null; + and Status in (?, ?, ?, ?) + ', [$this->EbookId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled, Enums\ProjectStatusType::AwaitingReview, Enums\ProjectStatusType::Reviewed], Project::class)[0] ?? null; } } diff --git a/lib/Enums/ProjectStatusType.php b/lib/Enums/ProjectStatusType.php index b1134ecd..b5dfbc32 100644 --- a/lib/Enums/ProjectStatusType.php +++ b/lib/Enums/ProjectStatusType.php @@ -3,6 +3,8 @@ namespace Enums; enum ProjectStatusType: string{ case InProgress = 'in_progress'; + case AwaitingReview = 'awaiting_review'; + case Reviewed = 'reviewed'; case Stalled = 'stalled'; case Completed = 'completed'; case Abandoned = 'abandoned'; @@ -10,6 +12,7 @@ enum ProjectStatusType: string{ public function GetDisplayName(): string{ return match($this){ self::InProgress => 'in progress', + self::AwaitingReview => 'awaiting review', default => $this->value }; } diff --git a/lib/Project.php b/lib/Project.php index 87bf7f72..5c90f6ae 100644 --- a/lib/Project.php +++ b/lib/Project.php @@ -9,6 +9,7 @@ use function Safe\preg_match; use function Safe\preg_match_all; use function Safe\preg_replace; +use Enums\ProjectStatusType; use Safe\DateTimeImmutable; /** @@ -342,7 +343,7 @@ final class Project{ } try{ - $this->FetchLatestCommitTimestamp(); + $this->FetchLastCommitTimestamp(); } catch(Exceptions\AppException){ // Pass; it's OK if this fails during creation. @@ -511,9 +512,69 @@ final class Project{ } /** + * Update this object's `Status` to `reviewed` if there is a GitHub issue containing the word `review`. + * * @throws Exceptions\AppException If the operation failed. */ - public function FetchLatestCommitTimestamp(?string $apiKey = null): void{ + public function FetchReviewStatus(?string $apiKey = null): void{ + if($this->Status == Enums\ProjectStatusType::Reviewed){ + return; + } + + if(!preg_match('|^https://github\.com/|iu', $this->VcsUrl ?? '')){ + return; + } + + $headers = [ + 'Accept: application/vnd.github+json', + 'X-GitHub-Api-Version: 2022-11-28', + 'User-Agent: Standard Ebooks' // Required by GitHub. + ]; + + if($apiKey !== null){ + $headers[] = 'Authorization: Bearer ' . $apiKey; + } + + $url = preg_replace('|^https://github.com/|iu', 'https://api.github.com/repos/', $this->VcsUrl . '/issues'); + + $curl = curl_init($url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + + try{ + $response = curl_exec($curl); + /** @var int $httpCode */ + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if(!is_string($response)){ + throw new Exceptions\AppException('Server did not respond with a string: ' . $response); + } + + if($httpCode != Enums\HttpCode::Ok->value){ + throw new Exception('Server responded with HTTP ' . $httpCode . '.'); + } + + /** @var array $issues */ + $issues = json_decode($response); + + foreach($issues as $issue){ + if(preg_match('/\breview/iu', $issue->title)){ + $this->Status = Enums\ProjectStatusType::Reviewed; + return; + } + } + } + catch(Exception $ex){ + throw new Exceptions\AppException('Error when fetching issues for URL <' . $url . '>: ' . $ex->getMessage(), 0, $ex); + } + } + + /** + * Update this object's `LastCommitTimestamp` with data from its GitHub repo. + * + * @throws Exceptions\AppException If the operation failed. + */ + public function FetchLastCommitTimestamp(?string $apiKey = null): void{ if(!preg_match('|^https://github\.com/|iu', $this->VcsUrl ?? '')){ return; } @@ -574,7 +635,9 @@ final class Project{ } /** - * @throws Exceptions\AppException If the operation faile.d + * Update this object's `LastDiscussionTimestamp` with data from its discussion page. + * + * @throws Exceptions\AppException If the operation failed. */ public function FetchLastDiscussionTimestamp(): void{ if(!preg_match('|^https://groups\.google\.com/g/standardebooks/|iu', $this->DiscussionUrl ?? '')){ @@ -628,6 +691,9 @@ final class Project{ return null; } + /** + * Send an email reminder to the producer notifying them about their project status. + */ public function SendReminder(Enums\ProjectReminderType $type): void{ if($this->ProducerEmail === null || $this->GetReminder($type) !== null){ return; @@ -693,18 +759,27 @@ final class Project{ return Db::MultiTableSelect('SELECT * from Projects inner join Ebooks on Projects.EbookId = Ebooks.EbookId where Projects.Status = ? order by regexp_replace(Title, \'^(A|An|The)\\\s\', \'\') asc', [$status], Project::class); } + /** + * @param array $statuses + * + * @return array + */ + public static function GetAllByStatuses(array $statuses): array{ + return Db::MultiTableSelect('SELECT * from Projects inner join Ebooks on Projects.EbookId = Ebooks.EbookId where Projects.Status in ' . Db::CreateSetSql($statuses) . ' order by regexp_replace(Title, \'^(A|An|The)\\\s\', \'\') asc', $statuses, Project::class); + } + /** * @return array */ public static function GetAllByManagerUserId(int $userId): array{ - return Db::MultiTableSelect('SELECT * from Projects inner join Ebooks on Projects.EbookId = Ebooks.EbookId where ManagerUserId = ? and Status in (?, ?) order by regexp_replace(Title, \'^(A|An|The)\\\s\', \'\') asc', [$userId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled], Project::class); + return Db::MultiTableSelect('SELECT * from Projects inner join Ebooks on Projects.EbookId = Ebooks.EbookId where ManagerUserId = ? and Status in (?, ?, ?, ?) order by regexp_replace(Title, \'^(A|An|The)\\\s\', \'\') asc', [$userId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled, Enums\ProjectStatusType::AwaitingReview, ProjectStatusType::Reviewed], Project::class); } /** * @return array */ public static function GetAllByReviewerUserId(int $userId): array{ - return Db::MultiTableSelect('SELECT * from Projects inner join Ebooks on Projects.EbookId = Ebooks.EbookId where ReviewerUserId = ? and Status in (?, ?) order by regexp_replace(Title, \'^(A|An|The)\\\s\', \'\') asc', [$userId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled], Project::class); + return Db::MultiTableSelect('SELECT * from Projects inner join Ebooks on Projects.EbookId = Ebooks.EbookId where ReviewerUserId = ? and Status in (?, ?, ?, ?) order by regexp_replace(Title, \'^(A|An|The)\\\s\', \'\') asc', [$userId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled, Enums\ProjectStatusType::AwaitingReview, ProjectStatusType::Reviewed], Project::class); } /** diff --git a/scripts/update-project-statuses b/scripts/update-project-statuses index 68ff2109..18da45cb 100755 --- a/scripts/update-project-statuses +++ b/scripts/update-project-statuses @@ -16,10 +16,7 @@ use Safe\DateTimeImmutable; */ /** @var array $projects */ -$projects = array_merge( - Project::GetAllByStatus(Enums\ProjectStatusType::InProgress), - Project::GetAllByStatus(Enums\ProjectStatusType::Stalled) - ); +$projects = Project::GetAllByStatuses([Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::AwaitingReview, Enums\ProjectStatusType::Reviewed, Enums\ProjectStatusType::Stalled]); $apiKey = trim(file_get_contents('/standardebooks.org/config/secrets/se-vcs-bot@api.github.com')); $oldestStalledTimestamp = new DateTimeImmutable('60 days ago'); @@ -34,7 +31,7 @@ foreach($projects as $project){ } try{ - $project->FetchLatestCommitTimestamp($apiKey); + $project->FetchLastCommitTimestamp($apiKey); } catch(Exceptions\AppException $ex){ @@ -42,8 +39,19 @@ foreach($projects as $project){ } if($project->IsStatusAutomaticallyUpdated){ + // Check if this project is in review. + if($project->Status == Enums\ProjectStatusType::InProgress){ + $project->FetchReviewStatus(); + } + if( - $project->Status == Enums\ProjectStatusType::InProgress + ( + $project->Status == Enums\ProjectStatusType::InProgress + || + $project->Status == Enums\ProjectStatusType::AwaitingReview + || + $project->Status == Enums\ProjectStatusType::Reviewed + ) && $project->LastActivityTimestamp < $oldestStalledTimestamp ){ diff --git a/templates/ProjectDetailsTable.php b/templates/ProjectDetailsTable.php index 4d1722bf..c0fbfa35 100644 --- a/templates/ProjectDetailsTable.php +++ b/templates/ProjectDetailsTable.php @@ -3,6 +3,8 @@ * @var Project $project */ +use Enums\HttpMethod; + $useFullyQualifiedUrls = $useFullyQualifiedUrls ?? false; $showTitle = $showTitle ?? true; $showArtworkStatus = $showArtworkStatus ?? true; @@ -69,5 +71,37 @@ $showArtworkStatus = $showArtworkStatus ?? true; + + Status: + + Benefits->CanEditProjects + || + $project->ManagerUserId == Session::$User?->UserId + || + $project->ReviewerUserId == Session::$User?->UserId + ){ ?> + +
+ + + +
+ + Status->GetDisplayName()) ?> + + + diff --git a/templates/ProjectForm.php b/templates/ProjectForm.php index 7b7bedbb..45da1f50 100644 --- a/templates/ProjectForm.php +++ b/templates/ProjectForm.php @@ -77,6 +77,8 @@ $isEditForm = $isEditForm ?? false;