Skip to content

Commit af927b3

Browse files
committed
feat: refactor GitHub integration routes and update API endpoints
- Changed integration routes from "/github" to "/integration" for clarity. - Updated repository-related endpoints to use "/repositories" instead of "/github/linked-repositories". - Added new endpoint for creating pull requests and fetching clone info. - Modified data structures for repositories and pull requests to include additional fields. - Updated frontend API calls to match new backend routes. - Enhanced error handling and logging for pull request creation.
1 parent c045b43 commit af927b3

9 files changed

Lines changed: 450 additions & 129 deletions

File tree

backend/client.go

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,20 +173,70 @@ func (c *ghClient) validateToken(ctx context.Context) error {
173173
}
174174

175175
func (c *ghClient) listRepositories(ctx context.Context) ([]ghRepository, error) {
176+
seen := make(map[string]struct{})
176177
var all []ghRepository
177-
page := 1
178-
for {
179-
url := fmt.Sprintf("%s/user/repos?affiliation=owner,collaborator&per_page=100&page=%d", ghBaseURL, page)
178+
179+
addRepo := func(r ghRepository) {
180+
if _, ok := seen[r.FullName]; ok {
181+
return
182+
}
183+
seen[r.FullName] = struct{}{}
184+
all = append(all, r)
185+
}
186+
187+
// 1. Repos directly accessible to the user (owned + collaborator + org member).
188+
for page := 1; ; page++ {
189+
url := fmt.Sprintf("%s/user/repos?visibility=all&affiliation=owner,collaborator,organization_member&per_page=100&page=%d", ghBaseURL, page)
180190
var batch []ghRepository
181191
if err := c.get(ctx, url, &batch); err != nil {
182192
return nil, err
183193
}
184-
all = append(all, batch...)
194+
for _, r := range batch {
195+
addRepo(r)
196+
}
197+
if len(batch) < 100 {
198+
break
199+
}
200+
}
201+
202+
// 2. List all orgs the user belongs to, then fetch each org's repos
203+
// explicitly. This catches orgs where the membership is private or
204+
// where the token has "read:org" but not full org repo visibility.
205+
var orgs []struct {
206+
Login string `json:"login"`
207+
}
208+
for page := 1; ; page++ {
209+
url := fmt.Sprintf("%s/user/orgs?per_page=100&page=%d", ghBaseURL, page)
210+
var batch []struct {
211+
Login string `json:"login"`
212+
}
213+
if err := c.get(ctx, url, &batch); err != nil {
214+
// Non-fatal: proceed with what we have from /user/repos.
215+
break
216+
}
217+
orgs = append(orgs, batch...)
185218
if len(batch) < 100 {
186219
break
187220
}
188-
page++
189221
}
222+
223+
for _, org := range orgs {
224+
for page := 1; ; page++ {
225+
url := fmt.Sprintf("%s/orgs/%s/repos?type=all&per_page=100&page=%d", ghBaseURL, org.Login, page)
226+
var batch []ghRepository
227+
if err := c.get(ctx, url, &batch); err != nil {
228+
// Skip orgs where the token lacks access.
229+
break
230+
}
231+
for _, r := range batch {
232+
addRepo(r)
233+
}
234+
if len(batch) < 100 {
235+
break
236+
}
237+
}
238+
}
239+
190240
return all, nil
191241
}
192242

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

287+
func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, head, base, body string) (*ghPullRequest, error) {
288+
url := fmt.Sprintf("%s/repos/%s/%s/pulls", ghBaseURL, owner, repo)
289+
reqBody := map[string]string{
290+
"title": title,
291+
"head": head,
292+
"base": base,
293+
}
294+
if body != "" {
295+
reqBody["body"] = body
296+
}
297+
var pr ghPullRequest
298+
if err := c.post(ctx, url, reqBody, &pr); err != nil {
299+
return nil, err
300+
}
301+
return &pr, nil
302+
}
303+
237304
func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error {
238305
refURL := fmt.Sprintf("%s/repos/%s/%s/git/ref/heads/%s", ghBaseURL, owner, repo, sourceBranch)
239306
var refResp struct {

backend/integration.go

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,43 @@ type integrationResponse struct {
2121
UpdatedAt *string `json:"updated_at,omitempty"`
2222
}
2323

24-
type repoInfoResponse struct {
24+
// accessibleRepoResponse is returned by GET /integration/accessible-repos.
25+
// It reflects data fetched live from the GitHub API.
26+
type accessibleRepoResponse struct {
2527
FullName string `json:"full_name"`
2628
Owner string `json:"owner"`
2729
RepoName string `json:"repo_name"`
2830
DefaultBranch string `json:"default_branch"`
2931
Private bool `json:"private"`
3032
}
3133

32-
type linkedRepoResponse struct {
34+
// repositoryResponse is the canonical DTO for a project-linked repository.
35+
// Returned by GET /repositories and POST /repositories.
36+
type repositoryResponse struct {
3337
ID string `json:"id"`
3438
ProjectID string `json:"project_id"`
3539
Owner string `json:"owner"`
3640
RepoName string `json:"repo_name"`
3741
FullName string `json:"full_name"`
3842
DefaultBranch string `json:"default_branch"`
43+
CloneURL string `json:"clone_url"`
3944
WebhookActive bool `json:"webhook_active"`
4045
CreatedAt string `json:"created_at"`
4146
UpdatedAt string `json:"updated_at"`
4247
}
4348

49+
// repoCloneInfo is returned by GET /repositories/:repoId/clone-info.
50+
// It includes a short-lived token for cloning.
51+
type repoCloneInfo struct {
52+
ID string `json:"id"`
53+
FullName string `json:"full_name"`
54+
Owner string `json:"owner"`
55+
RepoName string `json:"repo_name"`
56+
CloneURL string `json:"clone_url"`
57+
Token string `json:"token"`
58+
ExpiresAt float64 `json:"expires_at"`
59+
}
60+
4461
const githubPluginID = "com.paca.github"
4562

4663
func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, error) {
@@ -69,7 +86,7 @@ func (p *githubPlugin) decryptToken(projectID string) (string, error) {
6986
return p.decrypt(enc)
7087
}
7188

72-
// ─── GET /github ──────────────────────────────────────────────────────────────
89+
// ─── GET /integration ────────────────────────────────────────────────────────
7390

7491
func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) {
7592
projectID := req.Caller.ProjectID
@@ -96,7 +113,7 @@ func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response)
96113
})
97114
}
98115

99-
// ─── POST /github/token ───────────────────────────────────────────────────────
116+
// ─── POST /integration/token ─────────────────────────────────────────────────
100117

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

131148
now := nowStr()
132-
id := uuid.New().String()
133149
_, err = p.db.Exec(`
134-
INSERT INTO github_integrations (id, project_id, access_token_enc, created_at, updated_at)
135-
VALUES ($1, $2, $3, $4, $4)
136-
ON CONFLICT (project_id) DO UPDATE SET access_token_enc = $3, updated_at = $4
137-
`, id, projectID, enc, now)
150+
INSERT INTO github_integrations (project_id, access_token_enc, created_at, updated_at)
151+
VALUES ($1, $2, $3, $4)
152+
ON CONFLICT (project_id) DO UPDATE SET access_token_enc = EXCLUDED.access_token_enc, updated_at = EXCLUDED.updated_at
153+
`, projectID, enc, now, now)
138154
if err != nil {
139155
apiError(res, 500, "INTERNAL_ERROR", err.Error())
140156
return
@@ -160,7 +176,7 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) {
160176
})
161177
}
162178

163-
// ─── DELETE /github/token ─────────────────────────────────────────────────────
179+
// ─── DELETE /integration/token ───────────────────────────────────────────────
164180

165181
func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) {
166182
projectID := req.Caller.ProjectID
@@ -192,9 +208,9 @@ func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) {
192208
noContent(res)
193209
}
194210

195-
// ─── GET /github/repositories ────────────────────────────────────────────────
211+
// ─── GET /integration/accessible-repos ──────────────────────────────────────
196212

197-
func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) {
213+
func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Response) {
198214
projectID := req.Caller.ProjectID
199215

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

213-
items := make([]repoInfoResponse, len(repos))
229+
items := make([]accessibleRepoResponse, len(repos))
214230
for i, r := range repos {
215-
items[i] = repoInfoResponse{
231+
items[i] = accessibleRepoResponse{
216232
FullName: r.FullName,
217233
Owner: r.Owner.Login,
218234
RepoName: r.Name,
@@ -223,9 +239,9 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons
223239
ok(res, items)
224240
}
225241

226-
// ─── GET /github/linked-repositories ─────────────────────────────────────────
242+
// ─── GET /repositories ───────────────────────────────────────────────────────
227243

228-
func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.Response) {
244+
func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) {
229245
projectID := req.Caller.ProjectID
230246

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

240-
items := make([]linkedRepoResponse, 0, len(result.Rows))
256+
items := make([]repositoryResponse, 0, len(result.Rows))
241257
for _, row := range result.Rows {
242258
sc := newRowScanner(result.Columns, row)
243-
items = append(items, linkedRepoResponse{
259+
fullName := sc.str("full_name")
260+
items = append(items, repositoryResponse{
244261
ID: sc.str("id"),
245262
ProjectID: sc.str("project_id"),
246263
Owner: sc.str("owner"),
247264
RepoName: sc.str("repo_name"),
248-
FullName: sc.str("full_name"),
265+
FullName: fullName,
249266
DefaultBranch: sc.str("default_branch"),
267+
CloneURL: "https://github.com/" + fullName + ".git",
250268
WebhookActive: sc.int64Val("webhook_id") > 0,
251269
CreatedAt: sc.str("created_at"),
252270
UpdatedAt: sc.str("updated_at"),
@@ -255,7 +273,7 @@ func (p *githubPlugin) listLinkedRepositories(req *plugin.Request, res *plugin.R
255273
ok(res, items)
256274
}
257275

258-
// ─── POST /github/linked-repositories ────────────────────────────────────────
276+
// ─── POST /repositories ───────────────────────────────────────────────────────
259277

260278
func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) {
261279
projectID := req.Caller.ProjectID
@@ -352,20 +370,21 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response)
352370
return
353371
}
354372

355-
created(res, linkedRepoResponse{
373+
created(res, repositoryResponse{
356374
ID: repoID,
357375
ProjectID: projectID,
358376
Owner: ghRepo.Owner.Login,
359377
RepoName: ghRepo.Name,
360378
FullName: ghRepo.FullName,
361379
DefaultBranch: ghRepo.DefaultBranch,
380+
CloneURL: "https://github.com/" + ghRepo.FullName + ".git",
362381
WebhookActive: webhookID > 0,
363382
CreatedAt: now,
364383
UpdatedAt: now,
365384
})
366385
}
367386

368-
// ─── DELETE /github/linked-repositories/:repoId ───────────────────────────────
387+
// ─── DELETE /repositories/:repoId ────────────────────────────────────────────
369388

370389
func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Response) {
371390
projectID := req.Caller.ProjectID
@@ -405,6 +424,48 @@ func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Respons
405424
noContent(res)
406425
}
407426

427+
// ─── GET /repositories/:repoId/clone-info ────────────────────────────────────
428+
429+
func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Response) {
430+
projectID := req.Caller.ProjectID
431+
repoID := req.PathParam("repoId")
432+
if repoID == "" {
433+
apiError(res, 400, "BAD_REQUEST", "repoId path parameter is required")
434+
return
435+
}
436+
result, err := p.db.Query(
437+
`SELECT id, full_name, owner, repo_name FROM github_repositories WHERE project_id = $1 AND id = $2`,
438+
projectID, repoID,
439+
)
440+
if err != nil {
441+
apiError(res, 500, "INTERNAL_ERROR", err.Error())
442+
return
443+
}
444+
if len(result.Rows) == 0 {
445+
apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "repository not found")
446+
return
447+
}
448+
token, err := p.decryptToken(projectID)
449+
if err != nil {
450+
writeAppError(res, err)
451+
return
452+
}
453+
sc := newRowScanner(result.Columns, result.Rows[0])
454+
fullName := sc.str("full_name")
455+
ok(res, repoCloneInfo{
456+
ID: sc.str("id"),
457+
FullName: fullName,
458+
Owner: sc.str("owner"),
459+
RepoName: sc.str("repo_name"),
460+
CloneURL: "https://github.com/" + fullName + ".git",
461+
Token: token,
462+
ExpiresAt: 0,
463+
})
464+
}
465+
466+
// ─── GET /github/repo-info (REMOVED) ─────────────────────────────────────────
467+
// Replaced by GET /repositories (list all) and GET /repositories/:repoId/clone-info
468+
408469
// ─── Error helpers ────────────────────────────────────────────────────────────
409470

410471
type appError struct {

backend/plugin.go

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,27 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error {
3434
ctx.On("task.deleted", p.handleTaskDeleted)
3535
ctx.On("project.deleted", p.handleProjectDeleted)
3636

37-
// Integration routes (project-scoped)
38-
ctx.Route("GET", "/github", p.getIntegration)
39-
ctx.Route("POST", "/github/token", p.setToken)
40-
ctx.Route("DELETE", "/github/token", p.deleteToken)
41-
ctx.Route("GET", "/github/repositories", p.listRepositories)
42-
ctx.Route("GET", "/github/linked-repositories", p.listLinkedRepositories)
43-
ctx.Route("POST", "/github/linked-repositories", p.linkRepository)
44-
ctx.Route("DELETE", "/github/linked-repositories/:repoId", p.unlinkRepository)
45-
46-
// Task-scoped routes
47-
ctx.Route("GET", "/tasks/:taskId/github/pull-requests", p.listTaskPRs)
48-
ctx.Route("POST", "/tasks/:taskId/github/pull-requests", p.linkPRToTask)
49-
ctx.Route("DELETE", "/tasks/:taskId/github/pull-requests/:prId", p.unlinkPRFromTask)
50-
ctx.Route("POST", "/tasks/:taskId/github/branches", p.createBranch)
51-
ctx.Route("GET", "/tasks/:taskId/github/branches", p.listTaskBranches)
52-
53-
// Webhook – public endpoint, GitHub will POST events here.
37+
// ── Integration (GitHub token / connection) ───────────────────────────────
38+
ctx.Route("GET", "/integration", p.getIntegration)
39+
ctx.Route("POST", "/integration/token", p.setToken)
40+
ctx.Route("DELETE", "/integration/token", p.deleteToken)
41+
ctx.Route("GET", "/integration/accessible-repos", p.listAccessibleRepos)
42+
43+
// ── Repositories (repos linked to the project) ────────────────────────────
44+
ctx.Route("GET", "/repositories", p.listRepositories)
45+
ctx.Route("POST", "/repositories", p.linkRepository)
46+
ctx.Route("DELETE", "/repositories/:repoId", p.unlinkRepository)
47+
ctx.Route("GET", "/repositories/:repoId/clone-info", p.getRepoCloneInfo)
48+
49+
// ── Task resources ────────────────────────────────────────────────────────
50+
ctx.Route("GET", "/tasks/:taskId/pull-requests", p.listTaskPRs)
51+
ctx.Route("POST", "/tasks/:taskId/pull-requests", p.createPullRequest)
52+
ctx.Route("POST", "/tasks/:taskId/pull-requests/link", p.linkPRToTask)
53+
ctx.Route("DELETE", "/tasks/:taskId/pull-requests/:prId", p.unlinkPRFromTask)
54+
ctx.Route("GET", "/tasks/:taskId/branches", p.listTaskBranches)
55+
ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch)
56+
57+
// ── Webhook ───────────────────────────────────────────────────────────────
5458
ctx.Route("POST", "/webhook", p.receiveWebhook)
5559

5660
return nil

backend/plugin_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func callerReq() plugintest.Request {
6565

6666
func TestGetIntegration_NotConnected(t *testing.T) {
6767
tc := setupPlugin(t)
68-
res := tc.Call("GET", "/github", callerReq())
68+
res := tc.Call("GET", "/integration", callerReq())
6969

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

0 commit comments

Comments
 (0)