<?php
namespace App\Http\Controllers;
use App\Services\RepositoryService;
use App\Models\PullRequest;
use App\Models\GithubUser;
use App\Models\Item;
use App\Helpers\DiffRenderer;
use App\GithubConfig;
use App\Helpers\ApiHelper;
use Illuminate\Support\Facades\DB;
class PullRequestController extends Controller
{
public function metadata($organizationName, $repositoryName)
{
[$organization, $repository] = RepositoryService::getRepositoryWithOrganization($organizationName, $repositoryName);
$branches = $repository->branches()->get();
$branchNames = $branches->pluck('name');
$assignees = $repository->contributors()->with('githubUser')->get()->map(function ($contributor) {
return $contributor->githubUser;
});
$master_branch = $repository->master_branch;
$default_assignee = GithubConfig::USERID;
return response()->json([
'branches' => $branchNames,
'assignees' => $assignees,
'default_assignee' => $default_assignee,
'master_branch' => $master_branch,
]);
}
public function create($organizationName, $repositoryName)
{
[$organization, $repository] = RepositoryService::getRepositoryWithOrganization($organizationName, $repositoryName);
$response = \GrahamCampbell\GitHub\Facades\Github::pullRequests()->create($organization->name, $repository->name, [
'title' => request()->input('title'),
'head' => request()->input('head_branch'),
'base' => request()->input('base_branch'),
'assignee' => request()->input('assignee'),
'body' => request()->input('body', ''),
'draft' => true,
]);
// Ensure we have a proper structure to work with
if (!is_array($response) || !isset($response['id'])) {
return response()->json(['message' => 'Failed to create PR on GitHub'], 500);
}
// Create/update the user who opened the pull request
if (isset($response['user']) && is_array($response['user'])) {
GithubUser::updateFromWebhook((object) $response['user']);
}
$state = $response['state'] ?? 'open';
if (($state === 'closed') && !empty($response['merged'])) {
$state = 'merged';
}
// Determine merge base sha for accurate diffing
$mergeBaseSha = null;
$headSha = $response['head']['sha'] ?? null;
$baseRef = $response['base']['ref'] ?? null;
$headRef = $response['head']['ref'] ?? null;
if ($headSha && $baseRef && $headRef) {
$compareData = ApiHelper::githubApi("/repos/{$organization->name}/{$repository->name}/compare/{$baseRef}...{$headRef}");
if ($compareData && isset($compareData->merge_base_commit->sha)) {
$mergeBaseSha = $compareData->merge_base_commit->sha;
}
}
// Persist base fields in items table
$pr = PullRequest::updateOrCreate(
['id' => $response['id']],
[
'repository_id' => $repository->id,
'number' => $response['number'] ?? null,
'title' => $response['title'] ?? '',
'body' => $response['body'] ?? '',
'state' => $state,
'labels' => json_encode($response['labels'] ?? []),
'opened_by_id' => $response['user']['id'] ?? null,
]
);
// Persist PR-specific fields in pull_requests table
DB::table('pull_requests')->updateOrInsert(
['id' => $response['id']],
[
'head_branch' => $headRef,
'head_sha' => $headSha,
'base_branch' => $baseRef,
'merge_base_sha' => $mergeBaseSha,
'updated_at' => now(),
]
);
// Sync assignees (uses issue_assignees table)
$assigneeGithubIds = [];
if (!empty($response['assignees']) && is_array($response['assignees'])) {
foreach ($response['assignees'] as $assignee) {
if (is_array($assignee) && isset($assignee['id'])) {
$assigneeGithubIds[] = $assignee['id'];
GithubUser::updateFromWebhook((object) $assignee);
}
}
} elseif (!empty($response['assignee']) && is_array($response['assignee']) && isset($response['assignee']['id'])) {
// GitHub may return a single assignee
$assigneeGithubIds[] = $response['assignee']['id'];
GithubUser::updateFromWebhook((object) $response['assignee']);
}
if ($pr) {
$pr->assignees()->sync($assigneeGithubIds);
}
return response()->json([
'number' => $response['number'] ?? null,
'state' => $state,
]);
}
}
$q->orderBy('created_at', 'desc')->limit(1);
$q->orderBy('created_at', 'desc')->limit(1);
}]);
}]);
if (!$branch->commits->isEmpty()) {
$branch->last_commit = $branch->commits->first();
$branch->last_commit = $branch->commits->first();
$branch->last_commit->created_at_human = $branch->last_commit->created_at->diffForHumans();
$branch->last_commit->created_at_human = $branch->last_commit->created_at->diffForHumans();
}
$branchesForNotices[] = $branch;
$branchesForNotices[] = $branch;
}
}
'private' => $repoData->private,
'private' => $repoData->private,
'description' => $repoData->description ?? '',
'description' => $repoData->description ?? '',
'last_updated' => now(),
'last_updated' => now(),
'master_branch' => $repoData->default_branch,
]
]
);
);
}
}
'description',
'description',
'pr_count',
'pr_count',
'issue_count',
'issue_count',
'master_branch',
];
];
protected $casts = [
protected $casts = [
| is started. You may add your own aliases to this array as needed.
| is started. You may add your own aliases to this array as needed.
|
|
*/
*/
'aliases' => Illuminate\Support\Facades\Facade::defaultAliases()->merge([
'GitHub' => GrahamCampbell\GitHub\Facades\GitHub::class,
])->toArray(),
];
];
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('repositories', function (Blueprint $table) {
$table->string('master_branch')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('repositories', function (Blueprint $table) {
$table->dropColumn('master_branch');
});
}
};
import RepositoryDashboard from './RepositoryDashboard.svelte';
import RepositoryDashboard from './RepositoryDashboard.svelte';
import ItemOverview from './itemOverview/ItemOverview.svelte';
import ItemOverview from './itemOverview/ItemOverview.svelte';
import Item from './item/Item.svelte';
import Item from './item/Item.svelte';
import NewPullRequest from './item/pr/NewPullRequest.svelte';
import theme from '../theme.js';
import theme from '../theme.js';
const routes = {
const routes = {
'/:organization/:repository/issues': ItemOverview,
'/:organization/:repository/issues': ItemOverview,
'/:organization/:repository/issues/:number': Item,
'/:organization/:repository/issues/:number': Item,
'/:organization/:repository/prs': ItemOverview,
'/:organization/:repository/prs': ItemOverview,
'/:organization/:repository/new/pr/:branch': NewPullRequest,
'/:organization/:repository/prs/:number': Item,
'/:organization/:repository/prs/:number': Item,
};
};
<script>
let { name = 'input', label = "Label", value = $bindable(), placeholder = ' ', onChange } = $props();
function handleInput(event) {
value = event.target.value;
onChange?.({ value });
}
</script>
<div class="input-wrapper">
<label for={name}>{label}</label>
<input {name} {placeholder} bind:value={value} oninput={handleInput}>
</div>
<style lang="scss">
@import "../../scss/components/input";
</style>
<script>
import { onMount } from 'svelte';
import Sidebar from '../../sidebar/Sidebar.svelte';
import SidebarGroup from '../../sidebar/group.svelte';
import Select from '../../Select.svelte';
import Input from '../../Input.svelte';
import MarkdownEditor from '../../MarkdownEditor.svelte';
let { params = {} } = $props();
let head_branch = $state(params.branch);
let base_branch = $state('');
let possibleBranches = $state([]);
let title = $state('');
let body = $state('');
let assignee = $state();
let possibleAssignees = $state([]);
let loading = $state(false);
onMount(async () => {
// Load branches for the repository to populate the Select components
loading = true;
const res = await fetch(route(`organizations.repositories.pr.metadata`, { organization: params.organization, repository: params.repository }));
const data = await res.json();
// Ensure options are in { value, label } shape expected by <Select>
possibleBranches = (data.branches || []).map((b) => ({ value: b, label: b }));
possibleAssignees = (data.assignees || []).map((a) => ({ value: a.id, label: a.display_name }));
assignee = data.default_assignee;
base_branch = data.master_branch;
loading = false;
});
async function createPR() {
const res = await fetch(route(`organizations.repositories.pr.create`, { organization: params.organization, repository: params.repository }), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
head_branch,
base_branch,
title,
body,
assignee,
}),
});
if (res.ok) {
// window.location.href = `/${params.organization}/${params.repository}/prs/${res.json().number}`;
}
}
</script>
<div class="new-pr">
<Sidebar {params} selectedDropdownSection="New PR">
<SidebarGroup title="Branch to merge">
<Select name="head_branch" value={head_branch} selectableItems={possibleBranches} bind:selectedValue={head_branch} />
</SidebarGroup>
<SidebarGroup title="Branch to merge into">
<Select name="base_branch" value={base_branch} selectableItems={possibleBranches} bind:selectedValue={base_branch} />
</SidebarGroup>
<SidebarGroup title="Assignee">
<Select name="assignee" value={assignee} selectableItems={possibleAssignees} bind:selectedValue={assignee} />
</SidebarGroup>
</Sidebar>
<div class="new-pr-main">
<Input name="title" label="Title" bind:value={title} />
<MarkdownEditor bind:value={body} placeholder="Describe your changes..." />
<div class="submit-wrapper">
<button class="button-primary" onclick={createPR}>Create Pull Request</button>
</div>
</div>
</div>
<style lang="scss">
@import '../../../../scss/components/item/pr/new-pr.scss';
</style>
{:else}
{:else}
{#each branchesForNotice as branch}
{#each branchesForNotice as branch}
<PrNotice item={branch} />
<PrNotice item={branch} {params} />
{/each}
{/each}
<script>
<script>
let { item } = $props();
let { item, params } = $props();
const org = params.organization;
const repo = params.repository;
</script>
</script>
<div class="pr-notice">
<div class="pr-notice">
<div>
<b>{item.name}</b> had recent pushes
<b>{item.name}</b> had recent pushes
{#if item.last_commit}
{#if item.last_commit}
{item.last_commit.created_at_human}
{item.last_commit.created_at_human}
{/if}
{/if}
</div>
</div>
<a href="#/{org}/{repo}/new/pr/{item.name}" class="create-pr button-primary">Create PR</a>
</div>
<style lang="scss">
<style lang="scss">
@import '../../../scss/components/pr-notice';
@import '../../../scss/components/pr-notice';
width: fit-content;
width: fit-content;
padding: 0.5rem 1rem;
padding: 0.5rem 1rem;
height: 2.5rem;
height: 2.5rem;
font-size: 14px;
border-radius: 1rem;
border-radius: 1rem;
border: 2px solid transparent;
border: 2px solid transparent;
.input-wrapper {
position: relative;
margin: 0.5rem 0;
width: calc(100% - 2rem);
label {
position: absolute;
left: 0.75rem;
top: 0.75rem !important;
font-size: 14px !important;
color: var(--text-color-secondary);
background-color: var(--background-color-one);
padding: 0 0.25rem;
transform-origin: left top;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
&:has(input:focus),
&:has(input:not(:placeholder-shown)),
&:has(input[data-filled="true"]),
&:has(select:focus),
&:has(select:not(:invalid)) {
label {
left: 0.5rem;
transform: scale(0.8) translateY(-1.25rem);
}
}
input {
width: 100%;
padding: 0.5rem 1rem;
height: 1.5rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
background: var(--background-color-one);
outline: none;
cursor: pointer;
&:focus {
border-color: var(--primary-color);
}
}
}
.new-pr {
height: 100%;
display: flex;
gap: 1rem;
overflow: auto;
.new-pr-main {
width: calc(70% - 3rem);
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
}
.markdown-editor-container {
.markdown-editor-container {
width: 70%;
width: 100%;
border-radius: 0.5rem;
border-radius: 0.5rem;
overflow: hidden;
overflow: hidden;
background-color: var(--background-color);
background-color: var(--background-color);
border: 1px solid var(--border-color);
border: 1px solid var(--border-color);
}
}
:global(.markdown-editor-container .bytemd) {
color: var(--text-color);
}
:global(.markdown-editor-container .bytemd) {
:global(.markdown-editor-container .bytemd) {
height: fit-content;
height: fit-content;
border: none;
border: none;
:global(.markdown-editor-container .bytemd-body) {
:global(.markdown-editor-container .bytemd-body) {
background-color: var(--background-color);
background-color: var(--background-color);
color: var(--text-color);
}
}
:global(.markdown-editor-container .bytemd-editor) {
:global(.markdown-editor-container .bytemd-editor) {
font-size: 14px;
font-size: 14px;
}
}
/* Make editor token colors match our markdown theme (neutral text, themed links) */
:global(.markdown-editor-container .cm-s-default .cm-header),
:global(.markdown-editor-container .cm-s-default .cm-quote),
:global(.markdown-editor-container .cm-s-default .cm-keyword),
:global(.markdown-editor-container .cm-s-default .cm-atom),
:global(.markdown-editor-container .cm-s-default .cm-number),
:global(.markdown-editor-container .cm-s-default .cm-def),
:global(.markdown-editor-container .cm-s-default .cm-variable-2),
:global(.markdown-editor-container .cm-s-default .cm-variable-3),
:global(.markdown-editor-container .cm-s-default .cm-type),
:global(.markdown-editor-container .cm-s-default .cm-comment),
:global(.markdown-editor-container .cm-s-default .cm-string),
:global(.markdown-editor-container .cm-s-default .cm-string-2),
:global(.markdown-editor-container .cm-s-default .cm-meta),
:global(.markdown-editor-container .cm-s-default .cm-qualifier),
:global(.markdown-editor-container .cm-s-default .cm-builtin),
:global(.markdown-editor-container .cm-s-default .cm-bracket),
:global(.markdown-editor-container .cm-s-default .cm-tag),
:global(.markdown-editor-container .cm-s-default .cm-attribute),
:global(.markdown-editor-container .cm-s-default .cm-hr) {
color: var(--text-color) !important;
}
/* Links inside the editor */
:global(.markdown-editor-container .cm-s-default .cm-link) {
color: var(--link-color, var(--primary-color)) !important;
}
:global(.markdown-editor-container .CodeMirror-cursor) {
:global(.markdown-editor-container .CodeMirror-cursor) {
border-left-color: var(--text-color);
border-left-color: var(--text-color);
}
}
:global(.markdown-editor-container .bytemd-split) {
:global(.markdown-editor-container .bytemd-split) {
background-color: var(--border-color);
background-color: var(--border-color);
}
}
:global(.markdown-editor-container .bytemd-tippy-right) {
display: none !important;
}
:global(div[bytemd-tippy-path="0"]) {
display: none !important;
}
/* Styles for the lightweight custom editor, if used */
.markdown-editor-simple {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background-color: var(--background-color);
overflow: hidden;
}
.markdown-editor-simple .md-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 0.5rem;
background-color: var(--background-color-one);
border-bottom: 1px solid var(--border-color);
}
.markdown-editor-simple .md-toolbar .btn {
border: 1px solid var(--border-color);
background: var(--background-color);
color: var(--text-color);
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.markdown-editor-simple .md-toolbar .btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.markdown-editor-simple .md-toolbar .sep {
flex: 0 0 1px;
height: 20px;
background: var(--border-color);
margin: 0 2px;
}
.markdown-editor-simple .md-textarea {
width: 100%;
height: 320px;
resize: vertical;
border: 0;
outline: 0;
padding: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
background: var(--background-color);
color: var(--text-color);
caret-color: var(--text-color);
}
.markdown-editor-simple .md-textarea::placeholder {
color: var(--text-color-secondary);
}
border-radius: 1rem;
border-radius: 1rem;
border: 2px solid var(--primary-color-dark);
border: 2px solid var(--primary-color-dark);
color: var(--text-color-secondary);
color: var(--text-color-secondary);
display: flex;
justify-content: space-between;
align-items: center;
b {
b {
color: var(--text-color-secondary);
color: var(--text-color-secondary);
}
}
.create-pr {
text-decoration: none;
height: fit-content;
}
}
}
use App\Http\Middleware\IsLoggedIn;
use App\Http\Middleware\IsLoggedIn;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ItemController;
use App\Http\Controllers\ItemController;
use App\Http\Controllers\PullRequestController;
use App\Http\Controllers\ItemCommentController;
use App\Http\Controllers\ItemCommentController;
Route::middleware(IsLoggedIn::class)->group(function () {
Route::middleware(IsLoggedIn::class)->group(function () {
Route::get('/org/{organization}/repo/{repository}/branches/pr/notices', [RepositoryController::class, 'getBranchesForPRNotices'])
Route::get('/org/{organization}/repo/{repository}/branches/pr/notices', [RepositoryController::class, 'getBranchesForPRNotices'])
->name('organizations.repositories.branches.pr.notices');
->name('organizations.repositories.branches.pr.notices');
Route::get('/org/{organization}/repo/{repository}/pr/metadata', [PullRequestController::class, 'metadata'])
->name('organizations.repositories.pr.metadata');
Route::post('/org/{organization}/repo/{repository}/pr/create', [PullRequestController::class, 'create'])
->name('organizations.repositories.pr.create');
});
});
Route::any('incoming_hook', [IncomingWebhookController::class, 'index'])
Route::any('incoming_hook', [IncomingWebhookController::class, 'index'])
Testing assignees fix v2
Testing assignees fix