diff --git a/plugins/push-md/Tests/EndToEndTest.php b/plugins/push-md/Tests/EndToEndTest.php index fe56105b..4beb6ac4 100644 --- a/plugins/push-md/Tests/EndToEndTest.php +++ b/plugins/push-md/Tests/EndToEndTest.php @@ -61,6 +61,28 @@ public function testSeedStatusReportsDone() { $this->assertGreaterThan( 0, $state['total'], "Seeder reports zero total posts: $body" ); } + public function testLegacyBranchPreviewMigrationIsIdempotent() { + $branch = 'legacy/' . uniqid(); + $url = $this->base_url . '/wp-json/push-md-test/v1/migrate-legacy-preview'; + $first = $this->curl_post_json( $url, array( 'branch' => $branch ) ); + $this->assertSame( 200, $first['status'], 'Legacy migration helper failed: ' . $first['body'] ); + $first_result = json_decode( $first['body'], true ); + $this->assertNotEmpty( $first_result['id'] ); + $this->assertSame( 'push_md_active', $first_result['status'] ); + $this->assertSame( $branch, $first_result['branch'] ); + $this->assertSame( str_repeat( 'a', 40 ), $first_result['base_oid'] ); + $this->assertSame( str_repeat( 'b', 40 ), $first_result['tip_oid'] ); + $this->assertSame( 'pending', $first_result['review_state'] ); + $this->assertTrue( $first_result['option_removed'] ); + + $second = $this->curl_post_json( $url, array( 'branch' => $branch ) ); + $this->assertSame( 200, $second['status'], 'Repeated legacy migration failed: ' . $second['body'] ); + $second_result = json_decode( $second['body'], true ); + $this->assertSame( $first_result['id'], $second_result['id'] ); + $this->assertSame( 'pending', $second_result['review_state'] ); + $this->assertTrue( $second_result['option_removed'] ); + } + public function testSeedingSpansMultipleCronTicks() { // The CI workflow seeds 30 posts and drops a mu-plugin that // shrinks the batch size to 5 and the time budget to 0 @@ -167,7 +189,7 @@ public function testBranchPreviewPushRendersForAuthenticatedAdminWithoutMutating "---\nstatus: \"publish\"\ntitle: \"Branch Only $suffix\"\n---\n\n$new_text\n" ); $this->run_cmd( array( 'git', '-C', $clone_dir, 'add', 'post/' . $slug . '.md', 'post/' . $new_slug . '.md' ) ); - $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Preview branch content' ) ); + $this->run_cmd( array( 'git', '-C', $clone_dir, 'commit', '-m', 'Preview branch content', '-m', 'Adds a new post and updates the existing preview.' ) ); $push_result = $this->run_cmd( array( 'git', '-C', $clone_dir, 'push', 'origin', 'HEAD:refs/heads/' . $branch ) ); $this->assertStringContainsString( 'Push MD stored preview branch ' . $branch . ' without changing WordPress content.', $push_result['output'] ); @@ -216,7 +238,7 @@ public function testBranchPreviewPushRendersForAuthenticatedAdminWithoutMutating $this->assertSame( array( $branch ), $preview_response['headers']['x-push-md-preview-branch'] ); $this->assertSame( $revision_count_before, $this->count_revisions( $post_id, 'posts' ), 'Preview rendering must not create WordPress revisions.' ); - $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $branches = array( 'branches' => $this->get_pull_requests() ); $this->assertIsArray( $branches, 'Unexpected branch listing response.' ); $this->assertArrayHasKey( 'branches', $branches ); $branch_metadata = $this->find_branch_metadata( $branches['branches'], $branch ); @@ -231,6 +253,172 @@ public function testBranchPreviewPushRendersForAuthenticatedAdminWithoutMutating $this->assertStringContainsString( '/' . rawurlencode( $slug ) . '/?branch=' . $branch, $updated_preview_url['url'] ); $this->assertStringContainsString( '/' . rawurlencode( $new_slug ) . '/?branch=' . $branch, $created_preview_url['url'] ); $this->assertArrayHasNoTokenKeys( $branch_metadata ); + $this->assertNotEmpty( $branch_metadata['pull_request_id'] ); + $this->assertNotEmpty( $branch_metadata['diff']['files'] ); + $this->assertNotEmpty( $branch_metadata['diff']['commits'] ); + $this->assertSame( 'Preview branch content', $branch_metadata['diff']['commits'][0]['subject'] ); + $this->assertSame( 'Adds a new post and updates the existing preview.', $branch_metadata['diff']['commits'][0]['description'] ); + $this->assertSame( 'pending', $branch_metadata['review_state'] ); + + $description = 'This Pull Request updates preview content ' . $suffix; + $description_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $branch_metadata['pull_request_id'], + array( 'content' => $description ) + ); + $this->assertSame( 200, $description_response['status'], 'The active Pull Request description should be editable: ' . $description_response['body'] ); + $description_item = json_decode( $description_response['body'], true ); + $this->assertSame( $description, $description_item['content']['raw'] ); + + $general_note_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'General Pull Request note ' . $suffix, + ) + ); + $this->assertSame( 201, $general_note_response['status'], 'A general Pull Request Note should be created: ' . $general_note_response['body'] ); + $branches_after_general_note = array( 'branches' => $this->get_pull_requests() ); + $this->assertSame( 'pending', $this->find_branch_metadata( $branches_after_general_note['branches'], $branch )['review_state'], 'An ordinary Note must not change the review state.' ); + + $approved_note_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Approve Pull Request ' . $suffix, + 'meta' => array( 'push_md_review_state' => 'approved' ), + ) + ); + $this->assertSame( 201, $approved_note_response['status'], 'A state-changing Note should be created: ' . $approved_note_response['body'] ); + $approved_note = json_decode( $approved_note_response['body'], true ); + $this->assertSame( 'approved', $approved_note['meta']['push_md_review_state'] ); + $branches_after_approval = array( 'branches' => $this->get_pull_requests() ); + $this->assertSame( 'approved', $this->find_branch_metadata( $branches_after_approval['branches'], $branch )['review_state'] ); + + $approved_note_update = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments/' . $approved_note['id'], + array( 'content' => 'State-changing Notes are immutable.' ) + ); + $this->assertSame( 409, $approved_note_update['status'], 'A state-changing Note must not be edited.' ); + $approved_note_delete = $this->curl_delete( $this->base_url . '/wp-json/wp/v2/comments/' . $approved_note['id'] . '?force=true' ); + $this->assertSame( 409, $approved_note_delete['status'], 'A state-changing Note must not be deleted.' ); + + $invalid_state_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Invalid Pull Request state ' . $suffix, + 'meta' => array( 'push_md_review_state' => 'not_registered' ), + ) + ); + $this->assertSame( 400, $invalid_state_response['status'], 'An unregistered review state must be rejected.' ); + + $inline_anchor = $this->find_diff_anchor( $branch_metadata['diff']['files'] ); + $this->assertNotEmpty( $inline_anchor, 'The changed files should expose at least one inline Note anchor.' ); + $inline_note_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Inline Pull Request note ' . $suffix, + 'meta' => array( + 'push_md_path' => $inline_anchor['path'], + 'push_md_side' => $inline_anchor['side'], + 'push_md_line' => $inline_anchor['line'], + ), + ) + ); + $this->assertSame( 201, $inline_note_response['status'], 'An inline Pull Request Note should be created: ' . $inline_note_response['body'] ); + $inline_note = json_decode( $inline_note_response['body'], true ); + $this->assertSame( $inline_anchor['path'], $inline_note['meta']['push_md_path'] ); + $this->assertSame( $inline_anchor['side'], $inline_note['meta']['push_md_side'] ); + $this->assertSame( $inline_anchor['line'], $inline_note['meta']['push_md_line'] ); + $this->assertSame( $branch_metadata['tip_oid'], $inline_note['push_md_tip_oid'] ); + + $inline_state_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Inline state change ' . $suffix, + 'meta' => array( + 'push_md_path' => $inline_anchor['path'], + 'push_md_side' => $inline_anchor['side'], + 'push_md_line' => $inline_anchor['line'], + 'push_md_review_state' => 'approved', + ), + ) + ); + $this->assertSame( 400, $inline_state_response['status'], 'An inline Note must not change the review state.' ); + + $custom_state_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Request Pull Request changes ' . $suffix, + 'meta' => array( 'push_md_review_state' => 'changes_requested' ), + ) + ); + $this->assertSame( 201, $custom_state_response['status'], 'A filtered review state should be accepted: ' . $custom_state_response['body'] ); + $custom_state_note = json_decode( $custom_state_response['body'], true ); + $this->assertSame( 'changes_requested', $custom_state_note['meta']['push_md_review_state'] ); + $branches_after_custom_state = array( 'branches' => $this->get_pull_requests() ); + $this->assertSame( 'changes_requested', $this->find_branch_metadata( $branches_after_custom_state['branches'], $branch )['review_state'] ); + + $final_approval_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Approve corrected Pull Request ' . $suffix, + 'meta' => array( 'push_md_review_state' => 'approved' ), + ) + ); + $this->assertSame( 201, $final_approval_response['status'], 'A later Note should correct the review state: ' . $final_approval_response['body'] ); + + $invalid_anchor_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Invalid inline Pull Request note ' . $suffix, + 'meta' => array( + 'push_md_path' => $inline_anchor['path'], + 'push_md_side' => $inline_anchor['side'], + 'push_md_line' => 999999, + ), + ) + ); + $this->assertSame( 409, $invalid_anchor_response['status'], 'An inline Note must point at the current diff.' ); + + $incomplete_anchor_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Incomplete inline Pull Request note ' . $suffix, + 'meta' => array( 'push_md_path' => $inline_anchor['path'] ), + ) + ); + $this->assertSame( 400, $incomplete_anchor_response['status'], 'An inline Note must provide the complete anchor.' ); + + $direct_update_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $branch_metadata['pull_request_id'], + array( 'title' => 'REST must not update Pull Requests' ) + ); + $this->assertSame( 403, $direct_update_response['status'], 'The native controller must not update Pull Requests directly.' ); + $direct_create_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests', + array( 'title' => 'REST must not create Pull Requests' ) + ); + $this->assertSame( 403, $direct_create_response['status'], 'The native controller must not create Pull Requests directly.' ); + $direct_delete_response = $this->curl_delete( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $branch_metadata['pull_request_id'] . '?force=true' + ); + $this->assertSame( 403, $direct_delete_response['status'], 'The native controller must not delete Pull Requests directly.' ); $live_only_id = $this->create_post_via_rest( array( @@ -270,14 +458,33 @@ public function testBranchPreviewPushRendersForAuthenticatedAdminWithoutMutating $remote_branch = $this->run_cmd( array( 'git', 'ls-remote', $this->remote_url(), 'refs/heads/' . $branch ) ); $this->assertSame( '', trim( $remote_branch['output'] ), 'Merged preview branch ref should no longer be advertised.' ); - $branches_after_merge = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $branches_after_merge = array( 'branches' => $this->get_pull_requests() ); $merged_metadata = $this->find_branch_metadata( $branches_after_merge['branches'], $branch ); $this->assertNotEmpty( $merged_metadata, 'Merged preview branch metadata should remain available for history.' ); $this->assertSame( 'merged', $merged_metadata['status'] ); $this->assertFalse( $merged_metadata['active'] ); $this->assertNotEmpty( $merged_metadata['merged_at'] ); $this->assertSame( $branch, $merged_metadata['branch'] ); + $this->assertSame( 'approved', $merged_metadata['review_state'], 'Merge must preserve the review state.' ); $this->assertNotEmpty( $merged_metadata['changed_urls'], 'Merged branch history should keep the changed URL list.' ); + $merged_pull_request = json_decode( + $this->curl_get( $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $branch_metadata['pull_request_id'] . '?context=edit&_fields=content' ), + true + ); + $this->assertSame( $description, $merged_pull_request['content']['raw'], 'Merge must preserve the Pull Request description.' ); + $merged_description_update = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $branch_metadata['pull_request_id'], + array( 'content' => 'Merged descriptions are read-only.' ) + ); + $this->assertSame( 409, $merged_description_update['status'], 'A merged Pull Request description must be read-only.' ); + + $closed_note_update = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments/' . $inline_note['id'], + array( 'content' => 'Merged Pull Request Notes are immutable.' ) + ); + $this->assertSame( 409, $closed_note_update['status'], 'Merged Pull Request Notes must be read-only.' ); + $closed_note_delete = $this->curl_delete( $this->base_url . '/wp-json/wp/v2/comments/' . $inline_note['id'] . '?force=true' ); + $this->assertSame( 409, $closed_note_delete['status'], 'Merged Pull Request Notes must not be deleted.' ); } public function testPreviewBranchUpdatesRenderLatestBranchCommitWithoutMutatingLiveContent() { @@ -318,8 +525,26 @@ public function testPreviewBranchUpdatesRenderLatestBranchCommitWithoutMutatingL $this->assertSame( 200, $first_preview_response['status'], 'Authenticated preview request should render the first branch tip.' ); $this->assertStringContainsString( $first_preview_text, $first_preview_response['body'] ); $this->assertStringNotContainsString( $live_text, $first_preview_response['body'] ); - $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); - $this->assertSame( $first_tip, $this->find_branch_metadata( $branches['branches'], $branch )['tip_oid'] ); + $branches = array( 'branches' => $this->get_pull_requests() ); + $first_branch_metadata = $this->find_branch_metadata( $branches['branches'], $branch ); + $this->assertSame( $first_tip, $first_branch_metadata['tip_oid'] ); + $this->assertSame( 'pending', $first_branch_metadata['review_state'] ); + $description = 'Description preserved across pushes ' . $suffix; + $description_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $first_branch_metadata['pull_request_id'], + array( 'content' => $description ) + ); + $this->assertSame( 200, $description_response['status'], 'The active Pull Request description should be editable: ' . $description_response['body'] ); + $approval_response = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/comments', + array( + 'post' => $first_branch_metadata['pull_request_id'], + 'type' => 'note', + 'content' => 'Approve before another push ' . $suffix, + 'meta' => array( 'push_md_review_state' => 'approved' ), + ) + ); + $this->assertSame( 201, $approval_response['status'], 'The review state should be changeable before another push: ' . $approval_response['body'] ); $this->edit_file( $clone_dir . '/post/' . $slug . '.md', @@ -340,14 +565,35 @@ public function testPreviewBranchUpdatesRenderLatestBranchCommitWithoutMutatingL $this->assertStringContainsString( $next_preview_text, $next_preview_response['body'] ); $this->assertStringNotContainsString( $first_preview_text, $next_preview_response['body'] ); $this->assertStringNotContainsString( $live_text, $next_preview_response['body'] ); - $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); - $this->assertSame( $next_tip, $this->find_branch_metadata( $branches['branches'], $branch )['tip_oid'] ); + $branches = array( 'branches' => $this->get_pull_requests() ); + $next_branch_metadata = $this->find_branch_metadata( $branches['branches'], $branch ); + $this->assertSame( $next_tip, $next_branch_metadata['tip_oid'] ); + $this->assertSame( 'approved', $next_branch_metadata['review_state'], 'A later push must preserve the review state.' ); + $pull_request_after_push = json_decode( + $this->curl_get( $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $first_branch_metadata['pull_request_id'] . '?context=edit&_fields=content' ), + true + ); + $this->assertSame( $description, $pull_request_after_push['content']['raw'], 'A later push must preserve the Pull Request description.' ); $this->assertSame( $revision_count_before, $this->count_revisions( $post_id, 'posts' ), 'Preview branch updates must not create WordPress revisions.' ); $this->assertStringContainsString( $live_text, $this->fetch_content( $post_id, 'posts' ) ); $this->assertStringNotContainsString( $next_preview_text, $this->fetch_content( $post_id, 'posts' ) ); $this->delete_preview_branch( $clone_dir, $branch ); + $branches_after_close = array( 'branches' => $this->get_pull_requests() ); + $closed_metadata = $this->find_branch_metadata( $branches_after_close['branches'], $branch ); + $this->assertSame( 'closed', $closed_metadata['status'] ); + $this->assertSame( 'approved', $closed_metadata['review_state'], 'Closing a Pull Request must preserve the review state.' ); + $pull_request_after_close = json_decode( + $this->curl_get( $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $first_branch_metadata['pull_request_id'] . '?context=edit&_fields=content' ), + true + ); + $this->assertSame( $description, $pull_request_after_close['content']['raw'], 'Closing a Pull Request must preserve its description.' ); + $closed_description_update = $this->curl_post_json( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $first_branch_metadata['pull_request_id'], + array( 'content' => 'Closed descriptions are read-only.' ) + ); + $this->assertSame( 409, $closed_description_update['status'], 'A closed Pull Request description must be read-only.' ); } public function testPreviewBranchForceWithLeaseUpdateAfterRebaseResetsBaseToCurrentTrunk() { @@ -453,7 +699,7 @@ public function testPreviewBranchForceWithLeaseUpdateAfterRebaseResetsBaseToCurr $this->assertStringContainsString( $live_text, $this->fetch_content( $post_id, 'posts' ) ); $this->assertStringNotContainsString( $next_preview_text, $this->fetch_content( $post_id, 'posts' ) ); - $branches = json_decode( $this->curl_get( $this->base_url . '/wp-json/push-md/v1/branches' ), true ); + $branches = array( 'branches' => $this->get_pull_requests() ); $branch_metadata = $this->find_branch_metadata( $branches['branches'], $branch ); $this->assertSame( $next_tip, $branch_metadata['tip_oid'] ); $this->assertSame( $rebased_base, $branch_metadata['base_oid'], 'Rebased preview updates should reset the preview base to current trunk.' ); @@ -561,7 +807,7 @@ public function testPreviewBranchRendersChangedTemplatePartForAuthenticatedAdmin public function testPreviewBranchRestRoutesRequireAdmin() { $suffix = uniqid( 'branch-permission-' ); - $list_url = $this->base_url . '/wp-json/push-md/v1/branches'; + $list_url = $this->base_url . '/wp-json/wp/v2/push-md-pull-requests?context=edit&status=push_md_active,push_md_merged,push_md_closed'; $merge_url = $this->base_url . '/wp-json/push-md/v1/branches/merge'; $branch = 'preview/' . $suffix; $anonymous = $this->curl_get_with_headers( $list_url, false ); @@ -580,11 +826,19 @@ public function testPreviewBranchRestRoutesRequireAdmin() { $subscriber_list = $this->curl_get_with_headers( $list_url, $subscriber_header ); $subscriber_merge = $this->curl_post_json_with_auth( $merge_url, array( 'branch' => $branch ), $subscriber_header ); $admin_list = $this->curl_get_with_headers( $list_url, true ); + $admin_pull_requests = json_decode( $admin_list['body'], true ); + $this->assertNotEmpty( $admin_pull_requests, 'The permission test requires an existing Pull Request.' ); + $subscriber_update = $this->curl_post_json_with_auth( + $this->base_url . '/wp-json/wp/v2/push-md-pull-requests/' . $admin_pull_requests[0]['id'], + array( 'content' => 'Subscribers cannot edit Pull Request descriptions.' ), + $subscriber_header + ); $this->assertContains( $anonymous['status'], array( 401, 403 ), 'Anonymous branch list request should be denied.' ); $this->assertContains( $anon_merge['status'], array( 401, 403 ), 'Anonymous branch merge request should be denied.' ); $this->assertSame( 403, $subscriber_list['status'], 'Subscriber branch list request should be denied.' ); $this->assertSame( 403, $subscriber_merge['status'], 'Subscriber branch merge request should be denied.' ); + $this->assertSame( 403, $subscriber_update['status'], 'Subscriber Pull Request description updates should be denied.' ); $this->assertSame( 200, $admin_list['status'], 'Admin branch list request should be allowed.' ); } @@ -1363,10 +1617,10 @@ public function testFullRoundTrip() { $this->assertStringContainsString( 'Update template HTML from Git', $log['output'] ); $this->assertStringContainsString( 'Create and delete content from Git', $log['output'] ); - // 11) The CPT-based persistence model is gone. - $this->assertNotSame( + // 11) Pull Requests use the native CPT REST controller. + $this->assertSame( 200, - $this->http_status( $this->base_url . '/wp-json/wp/v2/types/pmd_commit' ) + $this->http_status( $this->base_url . '/wp-json/wp/v2/types/push_md_pull_request' ) ); } @@ -1641,6 +1895,26 @@ private function curl_post_json( $url, array $payload ) { return $this->curl_post_json_with_auth( $url, $payload, true ); } + private function curl_delete( $url ) { + $ch = curl_init( $url ); + curl_setopt_array( + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => array( $this->auth_header ), + ) + ); + $body = curl_exec( $ch ); + $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + + return array( + 'status' => $status, + 'body' => $body, + ); + } + private function curl_post_json_with_auth( $url, array $payload, $authenticated ) { $ch = curl_init( $url ); curl_setopt_array( @@ -1706,6 +1980,82 @@ private function count_revisions( $id, $endpoint ) { return count( $revisions ); } + private function get_pull_requests() { + $url = $this->base_url . '/wp-json/wp/v2/push-md-pull-requests' + . '?context=edit&per_page=100&status=push_md_active,push_md_merged,push_md_closed' + . '&_fields=id,status,date,modified,author,meta,push_md_preview_url,push_md_diff'; + $body = $this->curl_get( $url ); + $items = json_decode( $body, true ); + $this->assertIsArray( $items, 'Unexpected Pull Request listing response: ' . $body ); + + $pull_requests = array(); + foreach ( $items as $item ) { + $meta = isset( $item['meta'] ) && is_array( $item['meta'] ) ? $item['meta'] : array(); + $status = isset( $item['status'] ) ? $item['status'] : ''; + $diff = isset( $item['push_md_diff'] ) && is_array( $item['push_md_diff'] ) + ? $item['push_md_diff'] + : array( 'files' => array() ); + $changed_urls = array(); + foreach ( $diff['files'] as $file ) { + $changed_urls[] = array( + 'action' => isset( $file['action'] ) ? $file['action'] : '', + 'path' => isset( $file['path'] ) ? $file['path'] : '', + 'url' => isset( $file['preview_url'] ) ? $file['preview_url'] : '', + ); + } + + $pull_request = array( + 'pull_request_id' => isset( $item['id'] ) ? intval( $item['id'] ) : 0, + 'branch' => isset( $meta['push_md_branch'] ) ? $meta['push_md_branch'] : '', + 'owner' => isset( $item['author'] ) ? intval( $item['author'] ) : 0, + 'base_oid' => isset( $meta['push_md_base_oid'] ) ? $meta['push_md_base_oid'] : '', + 'tip_oid' => isset( $meta['push_md_tip_oid'] ) ? $meta['push_md_tip_oid'] : '', + 'review_state' => isset( $meta['push_md_review_state'] ) ? $meta['push_md_review_state'] : 'pending', + 'url' => isset( $item['push_md_preview_url'] ) ? $item['push_md_preview_url'] : '', + 'status' => str_replace( 'push_md_', '', $status ), + 'active' => 'push_md_active' === $status, + 'created_at' => isset( $item['date'] ) ? strtotime( $item['date'] ) : 0, + 'updated_at' => isset( $item['modified'] ) ? strtotime( $item['modified'] ) : 0, + 'changed_urls' => $changed_urls, + 'diff' => $diff, + ); + if ( 'push_md_merged' === $status ) { + $pull_request['merged_oid'] = isset( $meta['push_md_merged_oid'] ) ? $meta['push_md_merged_oid'] : ''; + $pull_request['merged_by'] = isset( $meta['push_md_merged_by'] ) ? intval( $meta['push_md_merged_by'] ) : 0; + $pull_request['merged_at'] = $pull_request['updated_at']; + } + $pull_requests[] = $pull_request; + } + + return $pull_requests; + } + + private function find_diff_anchor( $files ) { + foreach ( $files as $file ) { + if ( empty( $file['path'] ) || empty( $file['rows'] ) ) { + continue; + } + foreach ( $file['rows'] as $row ) { + if ( ! empty( $row['new_line'] ) ) { + return array( + 'path' => $file['path'], + 'side' => 'new', + 'line' => intval( $row['new_line'] ), + ); + } + if ( ! empty( $row['old_line'] ) ) { + return array( + 'path' => $file['path'], + 'side' => 'old', + 'line' => intval( $row['old_line'] ), + ); + } + } + } + + return array(); + } + private function find_branch_metadata( $branches, $branch_name ) { if ( ! is_array( $branches ) ) { return array(); diff --git a/plugins/push-md/Tests/ci-mu-test-helper.php b/plugins/push-md/Tests/ci-mu-test-helper.php index 885a7bbb..b82740e8 100644 --- a/plugins/push-md/Tests/ci-mu-test-helper.php +++ b/plugins/push-md/Tests/ci-mu-test-helper.php @@ -29,3 +29,78 @@ add_filter( 'push_md_seed_tick_reschedule_seconds', static function () { return 0; } ); + +add_filter( + 'push_md_review_states', + static function ( $states ) { + $states['changes_requested'] = array( + 'label' => 'Changes requested', + ); + + return $states; + } +); + +add_action( + 'rest_api_init', + static function () { + register_rest_route( + 'push-md-test/v1', + '/migrate-legacy-preview', + array( + 'methods' => 'POST', + 'permission_callback' => static function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => static function ( WP_REST_Request $request ) { + $branch_name = sanitize_text_field( $request->get_param( 'branch' ) ); + update_option( + 'push_md_branch_previews', + array( + $branch_name => array( + 'branch' => $branch_name, + 'owner' => get_current_user_id(), + 'base_oid' => str_repeat( 'a', 40 ), + 'tip_oid' => str_repeat( 'b', 40 ), + 'created_at' => 1700000000, + 'updated_at' => 1700000100, + ), + ), + false + ); + Push_MD_Pull_Requests::migrate_legacy_branch_previews(); + + $posts = get_posts( + array( + 'post_type' => Push_MD_Pull_Requests::POST_TYPE, + 'post_status' => Push_MD_Pull_Requests::STATUS_ACTIVE, + 'posts_per_page' => 1, + 'meta_key' => 'push_md_branch', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $branch_name, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + $post = empty( $posts ) ? null : $posts[0]; + + return rest_ensure_response( + array( + 'id' => $post ? $post->ID : 0, + 'status' => $post ? $post->post_status : '', + 'branch' => $post ? get_post_meta( $post->ID, 'push_md_branch', true ) : '', + 'base_oid' => $post ? get_post_meta( $post->ID, 'push_md_base_oid', true ) : '', + 'tip_oid' => $post ? get_post_meta( $post->ID, 'push_md_tip_oid', true ) : '', + 'review_state' => $post ? get_post_meta( $post->ID, 'push_md_review_state', true ) : '', + 'option_removed' => null === get_option( 'push_md_branch_previews', null ), + ) + ); + }, + 'args' => array( + 'branch' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); + } +); diff --git a/plugins/push-md/admin-shell.css b/plugins/push-md/admin-shell.css index d4c17f22..4db82be8 100644 --- a/plugins/push-md/admin-shell.css +++ b/plugins/push-md/admin-shell.css @@ -392,6 +392,329 @@ background: #edf6ff; color: #0a4b78; overflow-wrap: anywhere; + text-decoration: none; +} + +.push-md-review-state { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 22px; + margin: 0 0 8px 8px; + padding: 2px 8px; + border-radius: 999px; + background: #fff8c5; + color: #633c01; + font-size: 11px; + font-weight: 600; + vertical-align: middle; +} + +.push-md-review-state.is-approved { + background: #dafbe1; + color: #116329; +} + +.push-md-review-frame { + max-width: 1380px; +} + +.push-md-pr-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px 16px; + margin: 18px 0; + padding: 20px; + border: 1px solid var(--push-md-line); + border-radius: 8px; + background: var(--push-md-panel); +} + +.push-md-pr-header h1 { + margin: 0; + font-size: 24px; +} + +.push-md-pr-header .push-md-branch-meta { + grid-column: 2; +} + +.push-md-pr-states { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.push-md-pr-states .push-md-review-state { + min-height: 30px; + margin: 0; + padding: 4px 10px; + font-size: 12px; +} + +.push-md-pr-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 30px; + padding: 4px 10px; + border-radius: 999px; + background: #1f883d; + color: #fff; + font-size: 12px; + font-weight: 600; +} + +.push-md-pr-status.push_md_merged { + background: #8250df; +} + +.push-md-pr-status.push_md_closed { + background: #cf222e; +} + +.push-md-pr-actions { + display: flex; + grid-column: 3; + grid-row: 1 / span 2; + gap: 8px; +} + +.push-md-pr-description { + grid-column: 1 / -1; + margin-top: 4px; + padding-top: 16px; + border-top: 1px solid var(--push-md-line); +} + +.push-md-pr-description h2 { + margin: 0 0 6px; + font-size: 14px; +} + +.push-md-pr-description-content { + min-height: 40px; + padding: 8px; + border: 1px solid transparent; + border-radius: 6px; + white-space: pre-wrap; +} + +.push-md-pr-description-content.is-empty { + color: var(--push-md-muted); +} + +.push-md-pr-description-content[contenteditable="true"]:empty::before { + color: var(--push-md-muted); + content: attr(data-placeholder); +} + +.push-md-pr-description-content[contenteditable="true"]:hover { + background: #f6f8fa; +} + +.push-md-pr-description-content[contenteditable="true"]:focus { + border-color: #3858e9; + background: #fff; + outline: 1px solid #3858e9; +} + +.push-md-pr-description-form { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.push-md-pr-description-form .push-md-pr-description-content { + width: 100%; +} + +.push-md-pr-description-form button[hidden] { + display: none; +} + +.push-md-pr-commit-history { + margin-bottom: 18px; +} + +.push-md-pr-commit-list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.push-md-pr-commit-list li { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + align-items: start; + gap: 12px; + margin: 0; + padding: 10px 0; + border-top: 1px solid var(--push-md-line); +} + +.push-md-pr-commit-list code { + display: inline-block; + justify-self: start; + white-space: nowrap; +} + +.push-md-pr-commit-list li:first-child { + border-top: 0; +} + +.push-md-pr-commit-list li.is-empty { + display: block; + color: var(--push-md-muted); +} + +.push-md-pr-commit-copy { + display: flex; + flex-direction: column; + gap: 4px; +} + +.push-md-pr-commit-copy p { + margin: 0; + color: var(--push-md-muted); + white-space: pre-wrap; +} + +.push-md-notes { + display: flex; + flex-direction: column; + gap: 10px; +} + +.push-md-note { + margin: 8px 0; + border: 1px solid var(--push-md-line); + border-radius: 6px; + background: #fff; + overflow: hidden; +} + +.push-md-note-header { + padding: 7px 10px; + border-bottom: 1px solid var(--push-md-line); + background: #f6f8fa; + color: var(--push-md-muted); + font-size: 12px; + font-weight: 600; +} + +.push-md-note-content { + padding: 10px; + white-space: pre-wrap; +} + +.push-md-note-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + margin-top: 12px; + padding: 10px; + border: 1px solid var(--push-md-line); + border-radius: 6px; + background: #f6f8fa; +} + +#push-md-admin .push-md-note-form textarea { + grid-column: 1 / -1; + width: 100%; + min-height: 72px; +} + +.push-md-note-state-field { + display: flex; + align-items: center; + justify-self: end; + gap: 8px; + color: var(--push-md-muted); + font-size: 12px; +} + +.push-md-diff-file { + margin-top: 18px; + border: 1px solid var(--push-md-line); + border-radius: 8px; + background: #fff; + overflow: hidden; +} + +.push-md-diff-header { + display: flex; + align-items: center; + gap: 12px; + min-height: 46px; + padding: 10px 14px; + border-bottom: 1px solid var(--push-md-line); + background: #f6f8fa; +} + +.push-md-diff-header code { + flex: 1; + overflow-wrap: anywhere; +} + +.push-md-diff-rows { + overflow-x: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 12px; +} + +.push-md-diff-line { + display: grid; + grid-template-columns: 30px 52px 52px minmax(600px, 1fr); + min-height: 24px; + border-bottom: 1px solid #eef1f4; +} + +.push-md-diff-line > span { + padding: 3px 8px; + border-right: 1px solid #e5e7eb; + color: var(--push-md-muted); + text-align: right; + user-select: none; +} + +.push-md-diff-line > code { + padding: 3px 10px; + white-space: pre; +} + +.push-md-diff-line.is-added { + background: #dafbe1; +} + +.push-md-diff-line.is-deleted { + background: #ffebe9; +} + +.push-md-diff-add { + border: 0; + background: transparent; + color: #0969da; + cursor: pointer; + font-weight: 700; +} + +.push-md-diff-add:disabled { + color: transparent; + cursor: default; +} + +.push-md-diff-rows > .push-md-note, +.push-md-diff-rows > .push-md-inline-form { + margin: 8px 14px 8px 134px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .push-md-branch-meta { @@ -603,6 +926,21 @@ min-width: 0; } + .push-md-pr-header { + grid-template-columns: 1fr; + } + + .push-md-pr-header .push-md-branch-meta, + .push-md-pr-actions { + grid-column: 1; + grid-row: auto; + } + + .push-md-diff-rows > .push-md-note, + .push-md-diff-rows > .push-md-inline-form { + margin-left: 14px; + } + .push-md-changed-url-list li { grid-template-columns: 1fr; } diff --git a/plugins/push-md/admin-shell.js b/plugins/push-md/admin-shell.js index 1f78964c..e7c6c6ee 100644 --- a/plugins/push-md/admin-shell.js +++ b/plugins/push-md/admin-shell.js @@ -14,8 +14,12 @@ var nonce = config.nonce || ''; var statusUrl = config.statusUrl || ''; var retryUrl = config.retryUrl || ''; - var branchesUrl = config.branchesUrl || ''; + var pullRequestsUrl = config.pullRequestsUrl || ''; + var commentsUrl = config.commentsUrl || ''; var mergeBranchUrl = config.mergeBranchUrl || ''; + var adminPageUrl = config.adminPageUrl || ''; + var pullRequestId = intval( config.pullRequestId ); + var reviewStates = config.reviewStates || {}; var remoteUrl = config.remoteUrl || ''; var checkoutDir = config.checkoutDir || 'site'; var cloneCommand = config.cloneCommand || ('git clone ' + remoteUrl + ' ' + checkoutDir); @@ -32,6 +36,7 @@ var cwdEl = document.getElementById( 'push-md-prompt-cwd' ); var titleEl = document.getElementById( 'push-md-terminal-title' ); var commitListEl = document.getElementById( 'push-md-commit-list' ); + var branchPanelEl = document.getElementById( 'push-md-branches-panel' ); var branchListEl = document.getElementById( 'push-md-branch-list' ); var branchMessageEl = document.getElementById( 'push-md-branches-message' ); var branchRefreshEl = document.getElementById( 'push-md-branches-refresh' ); @@ -41,6 +46,11 @@ var historyIndex = 0; var hasAnnouncedReady = progress.state === 'done'; + if (pullRequestId) { + bootPullRequestReview(); + return; + } + if ( ! stateEl || ! outputEl || ! inputEl) { return; } @@ -157,13 +167,13 @@ } function fetchBranches() { - if ( ! branchesUrl || ! branchListEl || ! branchMessageEl) { + if ( ! pullRequestsUrl || ! branchListEl || ! branchMessageEl) { return; } - setBranchMessage( __( 'Loading preview branches...', 'push-md' ), 'is-muted' ); + setBranchMessage( __( 'Loading Pull Requests...', 'push-md' ), 'is-muted' ); fetch( - branchesUrl, + pullRequestsUrl + '?context=edit&per_page=100&status=push_md_active,push_md_merged,push_md_closed&_fields=id,title,status,date,modified,meta,push_md_preview_url', { credentials: 'same-origin', headers: { 'X-WP-Nonce': nonce } @@ -172,19 +182,39 @@ parseJsonResponse ).then( function (data) { - renderBranches( data.branches || [] ); + renderBranches( (data || []).map( normalizePullRequest ) ); } ).catch( function (error) { branchListEl.textContent = ''; - setBranchMessage( error.message || __( 'Could not load preview branches.', 'push-md' ), 'is-error' ); + setBranchMessage( error.message || __( 'Could not load Pull Requests.', 'push-md' ), 'is-error' ); } ); } + function normalizePullRequest(item) { + var meta = item.meta || {}; + return { + id: item.id, + branch: meta.push_md_branch || (item.title && (item.title.raw || item.title.rendered)) || '', + base_oid: meta.push_md_base_oid || '', + tip_oid: meta.push_md_tip_oid || '', + review_state: meta.push_md_review_state || 'pending', + description: item.content && (item.content.raw || item.content.rendered) ? (item.content.raw || stripHtml( item.content.rendered )) : '', + description_rendered: item.content && item.content.rendered ? item.content.rendered : '', + status: item.status || '', + url: item.push_md_preview_url || '', + pull_request_url: adminPageUrl + '&pr=' + item.id, + updated_at: Math.floor( Date.parse( item.modified || item.date || '' ) / 1000 ) + }; + } + function renderBranches(branches) { branchListEl.textContent = ''; - branches = Array.isArray( branches ) ? branches.slice( 0 ) : []; + branches = Array.isArray( branches ) ? branches.slice( 0 ) : []; + if (branchPanelEl) { + branchPanelEl.hidden = ! branches.length; + } branches.sort( function (left, right) { return intval( right.updated_at ) - intval( left.updated_at ); @@ -192,16 +222,19 @@ ); if ( ! branches.length) { - setBranchMessage( __( 'No preview branches.', 'push-md' ), 'is-muted' ); + setBranchMessage( __( 'No Pull Requests.', 'push-md' ), 'is-muted' ); return; } var activeBranches = []; var mergedBranches = []; + var closedBranches = []; branches.forEach( function (branch) { - if (branch.merged_at) { + if (branch.status === 'push_md_merged') { mergedBranches.push( branch ); + } else if (branch.status === 'push_md_closed') { + closedBranches.push( branch ); } else { activeBranches.push( branch ); } @@ -210,13 +243,16 @@ setBranchMessage( '' ); if (activeBranches.length) { - branchListEl.appendChild( createBranchSection( __( 'Active previews', 'push-md' ), activeBranches, 'is-active' ) ); + branchListEl.appendChild( createBranchSection( __( 'Open', 'push-md' ), activeBranches, 'is-active' ) ); } if (mergedBranches.length) { - branchListEl.appendChild( createBranchSection( __( 'Merged branches', 'push-md' ), mergedBranches, 'is-merged' ) ); + branchListEl.appendChild( createBranchSection( __( 'Merged', 'push-md' ), mergedBranches, 'is-merged' ) ); + } + if (closedBranches.length) { + branchListEl.appendChild( createBranchSection( __( 'Closed', 'push-md' ), closedBranches, 'is-closed' ) ); } if ( ! activeBranches.length) { - setBranchMessage( __( 'No active preview branches.', 'push-md' ), 'is-muted' ); + setBranchMessage( __( 'No open Pull Requests.', 'push-md' ), 'is-muted' ); } } @@ -224,8 +260,8 @@ var section = document.createElement( 'div' ); var heading = document.createElement( 'h3' ); - section.className = 'push-md-branch-section ' + className; - heading.className = 'push-md-branch-section-title'; + section.className = 'push-md-branch-section ' + className; + heading.className = 'push-md-branch-section-title'; heading.textContent = title; section.appendChild( heading ); @@ -241,22 +277,23 @@ function createBranchRow(branch) { var branchName = String( branch.branch || '' ); var previewUrl = String( branch.url || '' ); - var isMerged = Boolean( branch.merged_at ); + var isActive = branch.status === 'push_md_active'; var row = document.createElement( 'div' ); var details = document.createElement( 'div' ); var actions = document.createElement( 'div' ); - var name = document.createElement( 'code' ); + var name = document.createElement( 'a' ); var meta = document.createElement( 'div' ); var preview = document.createElement( 'a' ); var copy = document.createElement( 'button' ); var merge = document.createElement( 'button' ); - row.className = 'push-md-branch-row' + (isMerged ? ' is-merged' : ''); + row.className = 'push-md-branch-row' + (isActive ? '' : ' is-merged'); details.className = 'push-md-branch-details'; actions.className = 'push-md-branch-actions'; name.className = 'push-md-branch-name'; meta.className = 'push-md-branch-meta'; name.textContent = branchName; + name.href = branch.pull_request_url; meta.appendChild( createMetaItem( @@ -270,20 +307,17 @@ if (branch.base_oid) { meta.appendChild( createMetaItem( __( 'Base', 'push-md' ), shortOid( branch.base_oid ) ) ); } - if (branch.merged_at) { - meta.appendChild( createMetaItem( __( 'Merged', 'push-md' ), formatTimestamp( branch.merged_at ) ) ); - } - details.appendChild( name ); + details.appendChild( createReviewStateBadge( branch.review_state ) ); details.appendChild( meta ); - details.appendChild( createChangedUrlList( branch.changed_urls || [], isMerged ) ); - if (isMerged) { - merge.type = 'button'; - merge.className = 'button'; - merge.disabled = true; - merge.textContent = __( 'Merged', 'push-md' ); - actions.appendChild( merge ); + var review = document.createElement( 'a' ); + review.className = 'button'; + review.href = branch.pull_request_url; + review.textContent = __( 'Review', 'push-md' ); + actions.appendChild( review ); + + if ( ! isActive) { row.appendChild( details ); row.appendChild( actions ); @@ -316,7 +350,9 @@ } ); - actions.appendChild( preview ); + if (previewUrl) { + actions.appendChild( preview ); + } actions.appendChild( copy ); actions.appendChild( merge ); row.appendChild( details ); @@ -326,10 +362,10 @@ } function createMetaItem(label, value) { - var item = document.createElement( 'span' ); - var labelEl = document.createElement( 'span' ); - var valueEl = document.createElement( 'strong' ); - item.className = 'push-md-branch-meta-item'; + var item = document.createElement( 'span' ); + var labelEl = document.createElement( 'span' ); + var valueEl = document.createElement( 'strong' ); + item.className = 'push-md-branch-meta-item'; labelEl.textContent = label + ': '; valueEl.textContent = value; item.appendChild( labelEl ); @@ -338,13 +374,26 @@ return item; } + function reviewStateLabel(state) { + return reviewStates[state] && reviewStates[state].label ? reviewStates[state].label : state; + } + + function createReviewStateBadge(state) { + var badge = document.createElement( 'span' ); + state = String( state || 'pending' ); + badge.className = 'push-md-review-state is-' + state.replace( /[^a-z0-9_-]/g, '-' ); + badge.textContent = reviewStateLabel( state ); + + return badge; + } + function createChangedUrlList(changedUrls, isMerged) { - var list = document.createElement( 'ul' ); + var list = document.createElement( 'ul' ); list.className = 'push-md-changed-url-list'; - changedUrls = Array.isArray( changedUrls ) ? changedUrls : []; + changedUrls = Array.isArray( changedUrls ) ? changedUrls : []; if ( ! changedUrls.length) { - var empty = document.createElement( 'li' ); + var empty = document.createElement( 'li' ); empty.className = 'is-muted'; empty.textContent = __( 'No changed preview URLs available.', 'push-md' ); list.appendChild( empty ); @@ -390,6 +439,454 @@ return __( 'Changed', 'push-md' ); } + function bootPullRequestReview() { + var view = document.getElementById( 'push-md-pr-view' ); + var message = document.getElementById( 'push-md-pr-message' ); + if ( ! view || ! pullRequestsUrl || ! commentsUrl) { + return; + } + + message.textContent = __( 'Loading Pull Request...', 'push-md' ); + Promise.all( + [ + fetch( + pullRequestsUrl + '/' + pullRequestId + '?context=edit&_fields=id,title,content,status,date,modified,meta,push_md_diff,push_md_preview_url', + { credentials: 'same-origin', headers: { 'X-WP-Nonce': nonce } } + ).then( parseJsonResponse ), + fetch( + commentsUrl + '?context=edit&post=' + pullRequestId + '&type=note&status=all&per_page=100&_fields=id,author_name,date,content,meta,parent,push_md_tip_oid', + { credentials: 'same-origin', headers: { 'X-WP-Nonce': nonce } } + ).then( parseJsonResponse ) + ] + ).then( + function (results) { + message.textContent = ''; + renderPullRequestReview( normalizePullRequest( results[0] ), results[0].push_md_diff || { files: [] }, results[1] || [] ); + } + ).catch( + function (error) { + view.textContent = ''; + message.textContent = error.message || __( 'Could not load Pull Request.', 'push-md' ); + message.className = 'push-md-branch-message is-error'; + } + ); + } + + function renderPullRequestReview(pullRequest, diff, notes) { + var view = document.getElementById( 'push-md-pr-view' ); + var header = document.createElement( 'div' ); + var title = document.createElement( 'h1' ); + var status = document.createElement( 'span' ); + var reviewState = createReviewStateBadge( pullRequest.review_state ); + var statusGroup = document.createElement( 'div' ); + var meta = document.createElement( 'div' ); + var actions = document.createElement( 'div' ); + var isActive = pullRequest.status === 'push_md_active'; + var usedNoteIds = {}; + var generalNotes = notes.filter( + function (note) { + return ! note.meta || ! note.meta.push_md_path; + } + ); + + view.textContent = ''; + header.className = 'push-md-pr-header'; + title.textContent = pullRequest.branch; + status.className = 'push-md-pr-status ' + pullRequest.status; + status.textContent = pullRequest.status === 'push_md_merged' ? __( 'Merged', 'push-md' ) : (pullRequest.status === 'push_md_closed' ? __( 'Closed', 'push-md' ) : __( 'Open', 'push-md' )); + statusGroup.className = 'push-md-pr-states'; + statusGroup.appendChild( status ); + statusGroup.appendChild( reviewState ); + meta.className = 'push-md-branch-meta'; + meta.appendChild( createMetaItem( __( 'Base', 'push-md' ), shortOid( pullRequest.base_oid ) ) ); + meta.appendChild( createMetaItem( __( 'Tip', 'push-md' ), shortOid( pullRequest.tip_oid ) ) ); + meta.appendChild( createMetaItem( __( 'Updated', 'push-md' ), formatTimestamp( pullRequest.updated_at ) ) ); + actions.className = 'push-md-pr-actions'; + if (isActive && pullRequest.url) { + var preview = document.createElement( 'a' ); + preview.className = 'button'; + preview.href = pullRequest.url; + preview.target = '_blank'; + preview.rel = 'noopener noreferrer'; + preview.textContent = __( 'Preview', 'push-md' ); + actions.appendChild( preview ); + + var merge = document.createElement( 'button' ); + merge.type = 'button'; + merge.className = 'button button-primary'; + merge.textContent = __( 'Merge', 'push-md' ); + merge.addEventListener( + 'click', + function () { + mergeBranch( pullRequest.branch, merge ); } + ); + actions.appendChild( merge ); + } + header.appendChild( statusGroup ); + header.appendChild( title ); + header.appendChild( meta ); + header.appendChild( actions ); + header.appendChild( createDescriptionPanel( pullRequest, isActive ) ); + view.appendChild( header ); + view.appendChild( createCommitHistoryPanel( diff && diff.commits ? diff.commits : [] ) ); + view.appendChild( createConversationPanel( generalNotes, isActive, usedNoteIds ) ); + + var files = diff && Array.isArray( diff.files ) ? diff.files : []; + files.forEach( + function (file) { + view.appendChild( createDiffFile( file, notes, isActive, usedNoteIds ) ); + } + ); + if ( ! files.length) { + var empty = document.createElement( 'div' ); + empty.className = 'push-md-panel'; + empty.textContent = __( 'No changed files.', 'push-md' ); + view.appendChild( empty ); + } + + var unplaced = notes.filter( + function (note) { + return note.meta && note.meta.push_md_path && ! usedNoteIds[note.id]; + } + ); + if (unplaced.length) { + var unplacedPanel = document.createElement( 'div' ); + var unplacedTitle = document.createElement( 'h2' ); + unplacedPanel.className = 'push-md-panel'; + unplacedTitle.textContent = __( 'Unplaced comments', 'push-md' ); + unplacedPanel.appendChild( unplacedTitle ); + unplaced.forEach( + function (note) { + unplacedPanel.appendChild( createNote( note, noteAnchorLabel( note ) ) ); } + ); + view.appendChild( unplacedPanel ); + } + } + + function createDescriptionPanel(pullRequest, isActive) { + var panel = document.createElement( 'section' ); + var title = document.createElement( 'h2' ); + var description = document.createElement( 'div' ); + panel.className = 'push-md-pr-description'; + title.textContent = __( 'Description', 'push-md' ); + panel.appendChild( title ); + description.className = 'push-md-pr-description-content' + (pullRequest.description ? '' : ' is-empty'); + description.innerHTML = pullRequest.description_rendered || ''; + + if ( ! isActive) { + if ( ! pullRequest.description) { + description.textContent = __( 'No description provided.', 'push-md' ); + } + panel.appendChild( description ); + + return panel; + } + + var form = document.createElement( 'form' ); + var button = document.createElement( 'button' ); + var initialContent = description.innerHTML; + form.className = 'push-md-pr-description-form'; + description.contentEditable = 'true'; + description.setAttribute( 'role', 'textbox' ); + description.setAttribute( 'aria-multiline', 'true' ); + description.setAttribute( 'data-placeholder', __( 'Describe this Pull Request.', 'push-md' ) ); + button.type = 'submit'; + button.className = 'button button-primary'; + button.textContent = __( 'Save description', 'push-md' ); + button.hidden = true; + form.appendChild( description ); + form.appendChild( button ); + description.addEventListener( + 'input', + function () { + description.classList.toggle( 'is-empty', '' === description.textContent.trim() ); + button.hidden = description.innerHTML === initialContent; + } + ); + form.addEventListener( + 'submit', + function (event) { + event.preventDefault(); + if (button.hidden) { + return; + } + button.disabled = true; + updatePullRequestDescription( description.textContent.trim() ? description.innerHTML : '' ).catch( + function (error) { + button.disabled = false; + window.alert( error.message || __( 'Could not save the Pull Request description.', 'push-md' ) ); + } + ); + } + ); + panel.appendChild( form ); + + return panel; + } + + function createCommitHistoryPanel(commits) { + var panel = document.createElement( 'section' ); + var title = document.createElement( 'h2' ); + var list = document.createElement( 'ol' ); + panel.className = 'push-md-panel push-md-pr-commit-history'; + title.textContent = __( 'Commit history', 'push-md' ); + list.className = 'push-md-pr-commit-list'; + panel.appendChild( title ); + panel.appendChild( list ); + commits = Array.isArray( commits ) ? commits : []; + if ( ! commits.length) { + var empty = document.createElement( 'li' ); + empty.className = 'is-empty'; + empty.textContent = __( 'No commits in this Pull Request.', 'push-md' ); + list.appendChild( empty ); + + return panel; + } + + commits.forEach( + function (commit) { + var item = document.createElement( 'li' ); + var oid = document.createElement( 'code' ); + var copy = document.createElement( 'div' ); + var subject = document.createElement( 'strong' ); + oid.textContent = shortOid( commit.oid ); + subject.textContent = commit.subject || __( '(no message)', 'push-md' ); + copy.className = 'push-md-pr-commit-copy'; + item.appendChild( oid ); + copy.appendChild( subject ); + if (commit.description) { + var description = document.createElement( 'p' ); + description.textContent = commit.description; + copy.appendChild( description ); + } + item.appendChild( copy ); + list.appendChild( item ); + } + ); + + return panel; + } + + function createConversationPanel(notes, isActive, usedNoteIds) { + var panel = document.createElement( 'div' ); + var title = document.createElement( 'h2' ); + var list = document.createElement( 'div' ); + panel.className = 'push-md-panel push-md-conversation'; + title.textContent = __( 'Conversation', 'push-md' ); + list.className = 'push-md-notes'; + notes.forEach( + function (note) { + usedNoteIds[note.id] = true; + list.appendChild( createNote( note, '' ) ); + } + ); + panel.appendChild( title ); + panel.appendChild( list ); + if (isActive) { + panel.appendChild( createNoteForm( {}, __( 'Leave a comment', 'push-md' ), true ) ); + } + return panel; + } + + function createDiffFile(file, notes, isActive, usedNoteIds) { + var container = document.createElement( 'section' ); + var header = document.createElement( 'div' ); + var path = document.createElement( 'code' ); + var badge = document.createElement( 'span' ); + var rows = document.createElement( 'div' ); + container.className = 'push-md-diff-file'; + header.className = 'push-md-diff-header'; + path.textContent = file.path; + badge.className = 'push-md-changed-action'; + badge.textContent = formatChangedAction( file.action ); + header.appendChild( path ); + header.appendChild( badge ); + if (file.preview_url) { + var preview = document.createElement( 'a' ); + preview.href = file.preview_url; + preview.target = '_blank'; + preview.rel = 'noopener noreferrer'; + preview.textContent = __( 'Preview file', 'push-md' ); + header.appendChild( preview ); + } + rows.className = 'push-md-diff-rows'; + (file.rows || []).forEach( + function (row) { + var line = document.createElement( 'div' ); + var add = document.createElement( 'button' ); + var oldNumber = document.createElement( 'span' ); + var newNumber = document.createElement( 'span' ); + var content = document.createElement( 'code' ); + var anchorSide = row.new_line !== null ? 'new' : 'old'; + var anchorLine = row.new_line !== null ? row.new_line : row.old_line; + line.className = 'push-md-diff-line is-' + row.type; + add.type = 'button'; + add.className = 'push-md-diff-add'; + add.textContent = '+'; + add.title = __( 'Comment on this line', 'push-md' ); + add.disabled = ! isActive; + oldNumber.textContent = row.old_line === null ? '' : row.old_line; + newNumber.textContent = row.new_line === null ? '' : row.new_line; + content.textContent = row.content; + line.appendChild( add ); + line.appendChild( oldNumber ); + line.appendChild( newNumber ); + line.appendChild( content ); + if (isActive) { + add.addEventListener( + 'click', + function () { + var existing = line.nextSibling; + if (existing && existing.classList && existing.classList.contains( 'push-md-inline-form' )) { + existing.remove(); + return; + } + var form = createNoteForm( + { push_md_path: file.path, push_md_side: anchorSide, push_md_line: anchorLine }, + __( 'Comment on line', 'push-md' ) + ' ' + anchorLine + ); + form.classList.add( 'push-md-inline-form' ); + line.parentNode.insertBefore( form, line.nextSibling ); + } + ); + } + rows.appendChild( line ); + appendAnchoredNotes( rows, notes, file.path, row, usedNoteIds ); + } + ); + container.appendChild( header ); + container.appendChild( rows ); + return container; + } + + function appendAnchoredNotes(container, notes, path, row, usedNoteIds) { + notes.forEach( + function (note) { + var meta = note.meta || {}; + var matchesOld = meta.push_md_path === path && meta.push_md_side === 'old' && intval( meta.push_md_line ) === intval( row.old_line ); + var matchesNew = meta.push_md_path === path && meta.push_md_side === 'new' && intval( meta.push_md_line ) === intval( row.new_line ); + if (matchesOld || matchesNew) { + usedNoteIds[note.id] = true; + container.appendChild( createNote( note, noteAnchorLabel( note ) ) ); + } + } + ); + } + + function createNote(note, label) { + var element = document.createElement( 'article' ); + var header = document.createElement( 'div' ); + var content = document.createElement( 'div' ); + element.className = 'push-md-note'; + header.className = 'push-md-note-header'; + var stateLabel = note.meta && note.meta.push_md_review_state + ? sprintf( __( 'Changed state to %s', 'push-md' ), reviewStateLabel( note.meta.push_md_review_state ) ) + : ''; + header.textContent = (note.author_name || __( 'Reviewer', 'push-md' )) + (label ? ' · ' + label : '') + (stateLabel ? ' · ' + stateLabel : ''); + content.className = 'push-md-note-content'; + content.textContent = note.content && (note.content.raw || note.content.rendered) ? (note.content.raw || stripHtml( note.content.rendered )) : ''; + element.appendChild( header ); + element.appendChild( content ); + return element; + } + + function createNoteForm(meta, label, allowReviewState) { + var form = document.createElement( 'form' ); + var textarea = document.createElement( 'textarea' ); + var button = document.createElement( 'button' ); + var stateSelect = null; + form.className = 'push-md-note-form'; + textarea.rows = 3; + textarea.placeholder = label; + button.type = 'submit'; + button.className = 'button button-primary'; + button.textContent = __( 'Comment', 'push-md' ); + form.appendChild( textarea ); + if (allowReviewState) { + var stateField = document.createElement( 'label' ); + var stateFieldText = document.createElement( 'span' ); + stateSelect = document.createElement( 'select' ); + stateField.className = 'push-md-note-state-field'; + stateFieldText.textContent = __( 'Change state', 'push-md' ); + var noChange = document.createElement( 'option' ); + noChange.value = ''; + noChange.textContent = __( 'No state change', 'push-md' ); + stateSelect.appendChild( noChange ); + Object.keys( reviewStates ).forEach( + function (state) { + var option = document.createElement( 'option' ); + option.value = state; + option.textContent = reviewStateLabel( state ); + stateSelect.appendChild( option ); + } + ); + stateField.appendChild( stateFieldText ); + stateField.appendChild( stateSelect ); + form.appendChild( stateField ); + } + form.appendChild( button ); + form.addEventListener( + 'submit', + function (event) { + event.preventDefault(); + if ( ! textarea.value.trim()) { + return; + } + var submittedMeta = {}; + Object.keys( meta ).forEach( + function (key) { + submittedMeta[key] = meta[key]; } + ); + if (stateSelect && stateSelect.value) { + submittedMeta.push_md_review_state = stateSelect.value; + } + button.disabled = true; + postNote( textarea.value, submittedMeta ).catch( + function (error) { + button.disabled = false; + window.alert( error.message || __( 'Could not save comment.', 'push-md' ) ); + } + ); + } + ); + return form; + } + + function postNote(content, meta) { + return fetch( + commentsUrl, + { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, + body: JSON.stringify( { post: pullRequestId, type: 'note', content: content, meta: meta } ) + } + ).then( parseJsonResponse ).then( bootPullRequestReview ); + } + + function updatePullRequestDescription(content) { + return fetch( + pullRequestsUrl + '/' + pullRequestId, + { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, + body: JSON.stringify( { content: content } ) + } + ).then( parseJsonResponse ).then( bootPullRequestReview ); + } + + function noteAnchorLabel(note) { + var meta = note.meta || {}; + return String( meta.push_md_path || '' ) + ':' + String( meta.push_md_line || '' ) + ' (' + String( meta.push_md_side || '' ) + ')'; + } + + function stripHtml(value) { + var element = document.createElement( 'div' ); + element.innerHTML = String( value || '' ); + return element.textContent || ''; + } + function mergeBranch(branchName, button) { if ( ! mergeBranchUrl) { return; @@ -419,8 +916,12 @@ ).then( function (data) { setBranchMessage( sprintf( __( 'Merged %s into live content.', 'push-md' ), data.branch || branchName ), 'is-success' ); - fetchBranches(); - poll(); + if (pullRequestId) { + bootPullRequestReview(); + } else { + fetchBranches(); + poll(); + } } ).catch( function (error) { diff --git a/plugins/push-md/class-push-md-admin.php b/plugins/push-md/class-push-md-admin.php index 839f93de..8ca45858 100644 --- a/plugins/push-md/class-push-md-admin.php +++ b/plugins/push-md/class-push-md-admin.php @@ -13,9 +13,8 @@ class Push_MD_Admin { const REST_NAMESPACE = 'push-md/v1'; const STATUS_ROUTE = '/seed-status'; const RETRY_ROUTE = '/seed-retry'; - const BRANCHES_ROUTE = '/branches'; const MERGE_ROUTE = '/branches/merge'; - const ASSET_VERSION = '0.6.8'; + const ASSET_VERSION = '0.7.1'; public static function bootstrap() { add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) ); @@ -74,15 +73,6 @@ public static function register_rest_routes() { 'permission_callback' => array( __CLASS__, 'admin_only' ), ) ); - register_rest_route( - self::REST_NAMESPACE, - self::BRANCHES_ROUTE, - array( - 'methods' => 'GET', - 'callback' => array( __CLASS__, 'rest_branches' ), - 'permission_callback' => array( __CLASS__, 'admin_only' ), - ) - ); register_rest_route( self::REST_NAMESPACE, self::MERGE_ROUTE, @@ -119,14 +109,6 @@ public static function rest_retry() { return rest_ensure_response( Push_MD_Seeder::get_progress() ); } - public static function rest_branches() { - return rest_ensure_response( - array( - 'branches' => Push_MD_Plugin::list_preview_branches(), - ) - ); - } - public static function rest_merge_branch( WP_REST_Request $request ) { try { return rest_ensure_response( @@ -170,17 +152,24 @@ public static function render_page() { return; } + $pull_request_id = isset( $_GET['pr'] ) ? absint( wp_unslash( $_GET['pr'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Selects a read-only admin view. + if ( $pull_request_id ) { + self::render_pull_request_page( $pull_request_id ); + return; + } + // Drive the seeder before painting so the first screen already // has real checkout state whenever the host can do quick work. Push_MD_Seeder::drive( 1.5 ); - $progress = Push_MD_Seeder::get_progress(); - $nonce = wp_create_nonce( 'wp_rest' ); - $status_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::STATUS_ROUTE ) ); - $retry_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::RETRY_ROUTE ) ); - $branches_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::BRANCHES_ROUTE ) ); - $merge_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::MERGE_ROUTE ) ); - $git_url = esc_url_raw( rest_url( Push_MD_Plugin::ROUTE_NAMESPACE . '/md.git' ) ); - $user = wp_get_current_user(); + $progress = Push_MD_Seeder::get_progress(); + $nonce = wp_create_nonce( 'wp_rest' ); + $status_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::STATUS_ROUTE ) ); + $retry_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::RETRY_ROUTE ) ); + $pull_requests_url = esc_url_raw( rest_url( 'wp/v2/push-md-pull-requests' ) ); + $comments_url = esc_url_raw( rest_url( 'wp/v2/comments' ) ); + $merge_url = esc_url_raw( rest_url( self::REST_NAMESPACE . self::MERGE_ROUTE ) ); + $git_url = esc_url_raw( rest_url( Push_MD_Plugin::ROUTE_NAMESPACE . '/md.git' ) ); + $user = wp_get_current_user(); if ( $user && $user->exists() ) { $git_url = self::add_username_to_url( $git_url, $user->user_login ); } @@ -213,8 +202,11 @@ public static function render_page() { 'nonce' => $nonce, 'statusUrl' => $status_url, 'retryUrl' => $retry_url, - 'branchesUrl' => $branches_url, + 'pullRequestsUrl' => $pull_requests_url, + 'commentsUrl' => $comments_url, 'mergeBranchUrl' => $merge_url, + 'adminPageUrl' => admin_url( 'tools.php?page=' . self::PAGE_SLUG ), + 'reviewStates' => Push_MD_Pull_Requests::get_review_states(), 'remoteUrl' => $git_url, 'cloneCommand' => $clone_command, 'checkoutDir' => $site_slug, @@ -247,6 +239,15 @@ public static function render_page() { +