Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions backend/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,20 +173,70 @@ func (c *ghClient) validateToken(ctx context.Context) error {
}

func (c *ghClient) listRepositories(ctx context.Context) ([]ghRepository, error) {
seen := make(map[string]struct{})
var all []ghRepository
page := 1
for {
url := fmt.Sprintf("%s/user/repos?affiliation=owner,collaborator&per_page=100&page=%d", ghBaseURL, page)

addRepo := func(r ghRepository) {
if _, ok := seen[r.FullName]; ok {
return
}
seen[r.FullName] = struct{}{}
all = append(all, r)
}

// 1. Repos directly accessible to the user (owned + collaborator + org member).
for page := 1; ; page++ {
url := fmt.Sprintf("%s/user/repos?visibility=all&affiliation=owner,collaborator,organization_member&per_page=100&page=%d", ghBaseURL, page)
var batch []ghRepository
if err := c.get(ctx, url, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
for _, r := range batch {
addRepo(r)
}
if len(batch) < 100 {
break
}
}

// 2. List all orgs the user belongs to, then fetch each org's repos
// explicitly. This catches orgs where the membership is private or
// where the token has "read:org" but not full org repo visibility.
var orgs []struct {
Login string `json:"login"`
}
for page := 1; ; page++ {
url := fmt.Sprintf("%s/user/orgs?per_page=100&page=%d", ghBaseURL, page)
var batch []struct {
Login string `json:"login"`
}
if err := c.get(ctx, url, &batch); err != nil {
// Non-fatal: proceed with what we have from /user/repos.
break
}
orgs = append(orgs, batch...)
if len(batch) < 100 {
break
}
page++
}

for _, org := range orgs {
for page := 1; ; page++ {
url := fmt.Sprintf("%s/orgs/%s/repos?type=all&per_page=100&page=%d", ghBaseURL, org.Login, page)
var batch []ghRepository
if err := c.get(ctx, url, &batch); err != nil {
// Skip orgs where the token lacks access.
break
}
for _, r := range batch {
addRepo(r)
}
if len(batch) < 100 {
break
}
}
}

return all, nil
}

Expand Down Expand Up @@ -234,6 +284,23 @@ func (c *ghClient) getPullRequest(ctx context.Context, owner, repo string, prNum
return &pr, nil
}

func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, head, base, body string) (*ghPullRequest, error) {
url := fmt.Sprintf("%s/repos/%s/%s/pulls", ghBaseURL, owner, repo)
reqBody := map[string]string{
"title": title,
"head": head,
"base": base,
}
if body != "" {
reqBody["body"] = body
}
var pr ghPullRequest
if err := c.post(ctx, url, reqBody, &pr); err != nil {
return nil, err
}
return &pr, nil
}

func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error {
refURL := fmt.Sprintf("%s/repos/%s/%s/git/ref/heads/%s", ghBaseURL, owner, repo, sourceBranch)
var refResp struct {
Expand Down
105 changes: 83 additions & 22 deletions backend/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,43 @@ type integrationResponse struct {
UpdatedAt *string `json:"updated_at,omitempty"`
}

type repoInfoResponse struct {
// accessibleRepoResponse is returned by GET /integration/accessible-repos.
// It reflects data fetched live from the GitHub API.
type accessibleRepoResponse struct {
FullName string `json:"full_name"`
Owner string `json:"owner"`
RepoName string `json:"repo_name"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
}

type linkedRepoResponse struct {
// repositoryResponse is the canonical DTO for a project-linked repository.
// Returned by GET /repositories and POST /repositories.
type repositoryResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Owner string `json:"owner"`
RepoName string `json:"repo_name"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
CloneURL string `json:"clone_url"`
WebhookActive bool `json:"webhook_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

// repoCloneInfo is returned by GET /repositories/:repoId/clone-info.
// It includes a short-lived token for cloning.
type repoCloneInfo struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Owner string `json:"owner"`
RepoName string `json:"repo_name"`
CloneURL string `json:"clone_url"`
Token string `json:"token"`
ExpiresAt float64 `json:"expires_at"`
}

const githubPluginID = "com.paca.github"

func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, error) {
Expand Down Expand Up @@ -69,7 +86,7 @@ func (p *githubPlugin) decryptToken(projectID string) (string, error) {
return p.decrypt(enc)
}

// ─── GET /github ──────────────────────────────────────────────────────────────
// ─── GET /integration ────────────────────────────────────────────────────────

func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID
Expand All @@ -96,7 +113,7 @@ func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response)
})
}

// ─── POST /github/token ───────────────────────────────────────────────────────
// ─── POST /integration/token ─────────────────────────────────────────────────

func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID
Expand Down Expand Up @@ -129,12 +146,11 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) {
}

now := nowStr()
id := uuid.New().String()
_, err = p.db.Exec(`
INSERT INTO github_integrations (id, project_id, access_token_enc, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4)
ON CONFLICT (project_id) DO UPDATE SET access_token_enc = $3, updated_at = $4
`, id, projectID, enc, now)
INSERT INTO github_integrations (project_id, access_token_enc, created_at, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (project_id) DO UPDATE SET access_token_enc = EXCLUDED.access_token_enc, updated_at = EXCLUDED.updated_at
`, projectID, enc, now, now)
if err != nil {
apiError(res, 500, "INTERNAL_ERROR", err.Error())
return
Expand All @@ -160,7 +176,7 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) {
})
}

// ─── DELETE /github/token ─────────────────────────────────────────────────────
// ─── DELETE /integration/token ───────────────────────────────────────────────

func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID
Expand Down Expand Up @@ -192,9 +208,9 @@ func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) {
noContent(res)
}

// ─── GET /github/repositories ────────────────────────────────────────────────
// ─── GET /integration/accessible-repos ──────────────────────────────────────

func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) {
func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID

token, err := p.decryptToken(projectID)
Expand All @@ -210,9 +226,9 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons
return
}

items := make([]repoInfoResponse, len(repos))
items := make([]accessibleRepoResponse, len(repos))
for i, r := range repos {
items[i] = repoInfoResponse{
items[i] = accessibleRepoResponse{
FullName: r.FullName,
Owner: r.Owner.Login,
RepoName: r.Name,
Expand All @@ -223,9 +239,9 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons
ok(res, items)
}

// ─── GET /github/linked-repositories ─────────────────────────────────────────
// ─── GET /repositories ───────────────────────────────────────────────────────

func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.Response) {
func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID

result, err := p.db.Query(`
Expand All @@ -237,16 +253,18 @@ func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.R
return
}

items := make([]linkedRepoResponse, 0, len(result.Rows))
items := make([]repositoryResponse, 0, len(result.Rows))
for _, row := range result.Rows {
sc := newRowScanner(result.Columns, row)
items = append(items, linkedRepoResponse{
fullName := sc.str("full_name")
items = append(items, repositoryResponse{
ID: sc.str("id"),
ProjectID: sc.str("project_id"),
Owner: sc.str("owner"),
RepoName: sc.str("repo_name"),
FullName: sc.str("full_name"),
FullName: fullName,
DefaultBranch: sc.str("default_branch"),
CloneURL: "https://github.com/" + fullName + ".git",
WebhookActive: sc.int64Val("webhook_id") > 0,
CreatedAt: sc.str("created_at"),
UpdatedAt: sc.str("updated_at"),
Expand All @@ -255,7 +273,7 @@ func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.R
ok(res, items)
}

// ─── POST /github/linked-repositories ────────────────────────────────────────
// ─── POST /repositories ───────────────────────────────────────────────────────

func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID
Expand Down Expand Up @@ -352,20 +370,21 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response)
return
}

created(res, linkedRepoResponse{
created(res, repositoryResponse{
ID: repoID,
ProjectID: projectID,
Owner: ghRepo.Owner.Login,
RepoName: ghRepo.Name,
FullName: ghRepo.FullName,
DefaultBranch: ghRepo.DefaultBranch,
CloneURL: "https://github.com/" + ghRepo.FullName + ".git",
WebhookActive: webhookID > 0,
CreatedAt: now,
UpdatedAt: now,
})
}

// ─── DELETE /github/linked-repositories/:repoId ───────────────────────────────
// ─── DELETE /repositories/:repoId ────────────────────────────────────────────

func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID
Expand Down Expand Up @@ -405,6 +424,48 @@ func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Respons
noContent(res)
}

// ─── GET /repositories/:repoId/clone-info ────────────────────────────────────

func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Response) {
projectID := req.Caller.ProjectID
repoID := req.PathParam("repoId")
if repoID == "" {
apiError(res, 400, "BAD_REQUEST", "repoId path parameter is required")
return
}
result, err := p.db.Query(
`SELECT id, full_name, owner, repo_name FROM github_repositories WHERE project_id = $1 AND id = $2`,
projectID, repoID,
)
if err != nil {
apiError(res, 500, "INTERNAL_ERROR", err.Error())
return
}
if len(result.Rows) == 0 {
apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "repository not found")
return
}
token, err := p.decryptToken(projectID)
if err != nil {
writeAppError(res, err)
return
}
sc := newRowScanner(result.Columns, result.Rows[0])
fullName := sc.str("full_name")
ok(res, repoCloneInfo{
ID: sc.str("id"),
FullName: fullName,
Owner: sc.str("owner"),
RepoName: sc.str("repo_name"),
CloneURL: "https://github.com/" + fullName + ".git",
Token: token,
ExpiresAt: 0,
})
}

// ─── GET /github/repo-info (REMOVED) ─────────────────────────────────────────
// Replaced by GET /repositories (list all) and GET /repositories/:repoId/clone-info

// ─── Error helpers ────────────────────────────────────────────────────────────

type appError struct {
Expand Down
38 changes: 21 additions & 17 deletions backend/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,27 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error {
ctx.On("task.deleted", p.handleTaskDeleted)
ctx.On("project.deleted", p.handleProjectDeleted)

// Integration routes (project-scoped)
ctx.Route("GET", "/github", p.getIntegration)
ctx.Route("POST", "/github/token", p.setToken)
ctx.Route("DELETE", "/github/token", p.deleteToken)
ctx.Route("GET", "/github/repositories", p.listRepositories)
ctx.Route("GET", "/github/linked-repositories", p.listLinkedRepositories)
ctx.Route("POST", "/github/linked-repositories", p.linkRepository)
ctx.Route("DELETE", "/github/linked-repositories/:repoId", p.unlinkRepository)

// Task-scoped routes
ctx.Route("GET", "/tasks/:taskId/github/pull-requests", p.listTaskPRs)
ctx.Route("POST", "/tasks/:taskId/github/pull-requests", p.linkPRToTask)
ctx.Route("DELETE", "/tasks/:taskId/github/pull-requests/:prId", p.unlinkPRFromTask)
ctx.Route("POST", "/tasks/:taskId/github/branches", p.createBranch)
ctx.Route("GET", "/tasks/:taskId/github/branches", p.listTaskBranches)

// Webhook – public endpoint, GitHub will POST events here.
// ── Integration (GitHub token / connection) ───────────────────────────────
ctx.Route("GET", "/integration", p.getIntegration)
ctx.Route("POST", "/integration/token", p.setToken)
ctx.Route("DELETE", "/integration/token", p.deleteToken)
ctx.Route("GET", "/integration/accessible-repos", p.listAccessibleRepos)

// ── Repositories (repos linked to the project) ────────────────────────────
ctx.Route("GET", "/repositories", p.listRepositories)
ctx.Route("POST", "/repositories", p.linkRepository)
ctx.Route("DELETE", "/repositories/:repoId", p.unlinkRepository)
ctx.Route("GET", "/repositories/:repoId/clone-info", p.getRepoCloneInfo)

// ── Task resources ────────────────────────────────────────────────────────
ctx.Route("GET", "/tasks/:taskId/pull-requests", p.listTaskPRs)
ctx.Route("POST", "/tasks/:taskId/pull-requests", p.createPullRequest)
ctx.Route("POST", "/tasks/:taskId/pull-requests/link", p.linkPRToTask)
ctx.Route("DELETE", "/tasks/:taskId/pull-requests/:prId", p.unlinkPRFromTask)
ctx.Route("GET", "/tasks/:taskId/branches", p.listTaskBranches)
ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch)

// ── Webhook ───────────────────────────────────────────────────────────────
ctx.Route("POST", "/webhook", p.receiveWebhook)

return nil
Expand Down
2 changes: 1 addition & 1 deletion backend/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func callerReq() plugintest.Request {

func TestGetIntegration_NotConnected(t *testing.T) {
tc := setupPlugin(t)
res := tc.Call("GET", "/github", callerReq())
res := tc.Call("GET", "/integration", callerReq())

if res.StatusCode != 200 {
t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString())
Expand Down
Loading
Loading