Add 'awaiting review' and 'reviewed' project statuses that update from GitHub; allow project owners to update their project statuses

This commit is contained in:
Alex Cabal 2025-01-27 15:38:47 -06:00
parent b48f3a5798
commit 6378d687d8
12 changed files with 204 additions and 21 deletions

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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
};
}

View file

@ -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<stdClass> $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<Enums\ProjectStatusType> $statuses
*
* @return array<Project>
*/
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<Project>
*/
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<Project>
*/
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);
}
/**

View file

@ -16,10 +16,7 @@ use Safe\DateTimeImmutable;
*/
/** @var array<Project> $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
){

View file

@ -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;
</td>
</tr>
<? } ?>
<tr>
<td>Status:</td>
<td>
<? if(
Session::$User?->Benefits->CanEditProjects
||
$project->ManagerUserId == Session::$User?->UserId
||
$project->ReviewerUserId == Session::$User?->UserId
){ ?>
<form action="<?= $project->Url ?>" method="<?= HttpMethod::Post->value ?>" class="single-line-form">
<input type="hidden" name="_method" value="<?= HttpMethod::Patch->value ?>" />
<label class="icon meter">
<span>
<select name="project-status">
<option value="<?= Enums\ProjectStatusType::InProgress->value ?>"<? if($project->Status == Enums\ProjectStatusType::InProgress){?> selected="selected"<? } ?>>In progress</option>
<option value="<?= Enums\ProjectStatusType::AwaitingReview->value ?>"<? if($project->Status == Enums\ProjectStatusType::AwaitingReview){?> selected="selected"<? } ?>>Awaiting review</option>
<option value="<?= Enums\ProjectStatusType::Reviewed->value ?>"<? if($project->Status == Enums\ProjectStatusType::Reviewed){?> selected="selected"<? } ?>>Reviewed</option>
<option value="<?= Enums\ProjectStatusType::Stalled->value ?>"<? if($project->Status == Enums\ProjectStatusType::Stalled){?> selected="selected"<? } ?>>Stalled</option>
<option value="<?= Enums\ProjectStatusType::Completed->value ?>"<? if($project->Status == Enums\ProjectStatusType::Completed){?> selected="selected"<? } ?>>Completed</option>
<option value="<?= Enums\ProjectStatusType::Abandoned->value ?>"<? if($project->Status == Enums\ProjectStatusType::Abandoned){?> selected="selected"<? } ?>>Abandoned</option>
</select>
</span>
</label>
<button>Save changes</button>
</form>
<? }else{ ?>
<?= ucfirst($project->Status->GetDisplayName()) ?>
<? } ?>
</td>
</tr>
</tbody>
</table>

View file

@ -77,6 +77,8 @@ $isEditForm = $isEditForm ?? false;
<span>
<select name="project-status">
<option value="<?= Enums\ProjectStatusType::InProgress->value ?>"<? if($project->Status == Enums\ProjectStatusType::InProgress){?> selected="selected"<? } ?>>In progress</option>
<option value="<?= Enums\ProjectStatusType::AwaitingReview->value ?>"<? if($project->Status == Enums\ProjectStatusType::AwaitingReview){?> selected="selected"<? } ?>>Awaiting review</option>
<option value="<?= Enums\ProjectStatusType::Reviewed->value ?>"<? if($project->Status == Enums\ProjectStatusType::Reviewed){?> selected="selected"<? } ?>>Reviewed</option>
<option value="<?= Enums\ProjectStatusType::Stalled->value ?>"<? if($project->Status == Enums\ProjectStatusType::Stalled){?> selected="selected"<? } ?>>Stalled</option>
<option value="<?= Enums\ProjectStatusType::Completed->value ?>"<? if($project->Status == Enums\ProjectStatusType::Completed){?> selected="selected"<? } ?>>Completed</option>
<option value="<?= Enums\ProjectStatusType::Abandoned->value ?>"<? if($project->Status == Enums\ProjectStatusType::Abandoned){?> selected="selected"<? } ?>>Abandoned</option>

View file

@ -59,7 +59,7 @@ $showEditButton = $showEditButton ?? false;
<? } ?>
</td>
<? if($includeStatus){ ?>
<td class="status<? if($project->Status == Enums\ProjectStatusType::Stalled){ ?> stalled<? } ?>">
<td class="status<? if($project->Status == Enums\ProjectStatusType::Stalled){ ?> stalled<? } ?><? if($project->Status == Enums\ProjectStatusType::AwaitingReview){ ?> awaiting-review<? } ?><? if($project->Status == Enums\ProjectStatusType::Reviewed){ ?> reviewed<? } ?>">
<?= ucfirst($project->Status->GetDisplayName()) ?>
</td>
<? } ?>

View file

@ -1927,7 +1927,7 @@ label:has(select) > span + span:last-child{
display: inline-block;
}
label:has(select) > span + span:last-child::after{
label:has(select) > span:has(select)::after{
display: block;
font-style: normal;
position: absolute;
@ -2227,6 +2227,15 @@ main nav.pagination ol li.highlighted:nth-last-child(2)::after{
display: none;
}
.single-line-form{
display: flex;
gap: 1rem;
}
.single-line-form button{
margin: 0;
}
.has-hero hgroup{
padding: 2rem 0 1.75rem 0;
display: flex;
@ -3786,6 +3795,14 @@ a.patron-selection:any-link:hover{
gap: 0;
padding: .5rem .75rem;
}
table.admin-table .single-line-form{
flex-direction: column;
}
table.admin-table .single-line-form button{
align-self: flex-end;
}
}
@media(max-width: 680px){

View file

@ -35,11 +35,25 @@ table.data-table{
width: 100%;
}
table.data-table td.status{
text-align: center;
}
table.data-table td.status.stalled{
background: #861d1d !important; /* Override hover backgound color */
color: #ffffff;
}
table.data-table td.status.awaiting-review{
background: #d5b434 !important; /* Override hover backgound color */
color: #ffffff;
}
table.data-table td.status.reviewed{
background: #1d863c !important; /* Override hover backgound color */
color: #ffffff;
}
@media(max-width: 750px){
.project-form{
grid-template-columns: 1fr;

View file

@ -28,7 +28,7 @@ try{
session_unset();
}
$inProgressProjects = Project::GetAllByStatus(Enums\ProjectStatusType::InProgress);
$inProgressProjects = Project::GetAllByStatuses([Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::AwaitingReview, Enums\ProjectStatusType::Reviewed]);
$stalledProjects = Project::GetAllByStatus(Enums\ProjectStatusType::Stalled);
}
catch(Exceptions\LoginRequiredException){

View file

@ -10,12 +10,12 @@ try{
throw new Exceptions\LoginRequiredException();
}
if(!Session::$User->Benefits->CanEditProjects){
throw new Exceptions\InvalidPermissionsException();
}
// POSTing a new `Project`.
if($httpMethod == Enums\HttpMethod::Post){
if(!Session::$User->Benefits->CanEditProjects){
throw new Exceptions\InvalidPermissionsException();
}
$project = new Project();
$project->FillFromHttpPost();
@ -69,6 +69,10 @@ try{
// PUTing a `Project`.
if($httpMethod == Enums\HttpMethod::Put){
if(!Session::$User->Benefits->CanEditProjects){
throw new Exceptions\InvalidPermissionsException();
}
$project = Project::Get(HttpInput::Int(GET, 'project-id'));
$exceptionRedirectUrl = $project->EditUrl;
@ -80,6 +84,32 @@ try{
http_response_code(Enums\HttpCode::SeeOther->value);
header('Location: ' . $project->Ebook->Url);
}
// PATCHing a `Project`.
if($httpMethod == Enums\HttpMethod::Patch){
$project = Project::Get(HttpInput::Int(GET, 'project-id'));
$exceptionRedirectUrl = $project->EditUrl;
if(
!Session::$User->Benefits->CanEditProjects
&&
(
$project->ManagerUserId != Session::$User->UserId
||
$project->ReviewerUserId != Session::$User->UserId
)
){
throw new Exceptions\InvalidPermissionsException();
}
$project->PropertyFromHttp('Status');
$project->Save();
$_SESSION['is-project-saved'] = true;
http_response_code(Enums\HttpCode::SeeOther->value);
header('Location: ' . $project->Ebook->Url);
}
}
catch(Exceptions\EbookNotFoundException){
Template::ExitWithCode(Enums\HttpCode::NotFound);