Files
kandev/apps/backend/internal/gitlab/client_helpers.go
T
João Salavisa 9fabc7f5cd fix(gitlab): scope /gitlab page tabs to the authenticated user (#1068)
* fix(gitlab): scope /gitlab page tabs to the authenticated user

The Assigned/Authored/Review-requested tabs sent the bare tab token
(`assigned_to_me`, `created_by_me`, `review_requested`) as the `filter`
query param. The backend parsed `filter` as a `key=value` string via
`appendFilter`, so the token contributed nothing — combined with the
hardcoded `scope=all`, the page served the global, unscoped MR/issue
listing instead of the user's own queue.

Add `translateUserSearchFilter` to map the well-known tab tokens to the
correct GitLab params (`scope=assigned_to_me`, `scope=created_by_me`,
or `reviewer_username=<me>&scope=all` for review-requested) and call it
from the two user-search controller handlers. `custom_query` and raw
`key=value` filters keep their existing power-user passthrough.

Cover the fix with unit tests on the translator, wire-shape tests on
`SearchMRsPaged`/`ListIssuesPaged`, and a new `controller_test.go` that
drives the controller end-to-end against an `httptest` GitLab stub.

* fix(gitlab): 500 when review_requested resolves to empty GitLab username

Review follow-up: translateMRFilter previously surfaced a 500 only when
GetAuthenticatedUser returned an error. If the call succeeded but
produced an empty username (NoopClient, an unexpected GitLab response),
translateUserSearchFilter returned an empty string and the controller
silently fell through to SearchMRsPaged with the bare "review_requested"
token — appendFilter parsed it as an empty-value key and the page served
the unscoped global listing, re-introducing the original bug.

Now error out with a 500 in that case and cover it with a regression
test that asserts the upstream /merge_requests endpoint is never even
called when the username comes back blank.

* fix(gitlab): reject review_requested on issues endpoint with 400

Silently falling through to an unscoped listing when filter=review_requested
is sent to /gitlab/user/issues would re-introduce the exact bug this PR fixes
for MRs. GitLab has no reviewer-assigned concept for issues, so the controller
now returns 400 Bad Request and short-circuits before calling ListIssuesPaged.

Also extracts filterTokenReviewRequested as a package constant (goconst
enforces this for the three occurrences across controller.go and
client_helpers.go) and adds a regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(gitlab): use filterTokenReviewRequested constant in translateMRFilter

Missed substitution from the previous commit: translateMRFilter still
compared against the raw "review_requested" literal instead of the new
package constant. No behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:17:49 +01:00

498 lines
14 KiB
Go

package gitlab
import (
"net/url"
"strings"
"time"
)
// rawMR is the JSON shape of a GitLab merge request as returned by the
// REST v4 API.
type rawMR struct {
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"` // opened, closed, merged, locked
WebURL string `json:"web_url"`
Draft bool `json:"draft"`
WorkInProgress bool `json:"work_in_progress"`
MergeStatus string `json:"merge_status"`
HasConflicts bool `json:"has_conflicts"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
SHA string `json:"sha"`
References struct {
Full string `json:"full"`
} `json:"references"`
Author rawUser `json:"author"`
Reviewers []rawUser `json:"reviewers"`
Assignees []rawUser `json:"assignees"`
ChangesCount string `json:"changes_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedAt *time.Time `json:"merged_at"`
ClosedAt *time.Time `json:"closed_at"`
}
type rawUser struct {
Username string `json:"username"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Bot bool `json:"bot"`
}
type rawProject struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
PathWithNamespace string `json:"path_with_namespace"`
Visibility string `json:"visibility"`
Namespace struct {
FullPath string `json:"full_path"`
} `json:"namespace"`
}
type rawIssue struct {
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
WebURL string `json:"web_url"`
Author rawUser `json:"author"`
Labels []string `json:"labels"`
Assignees []rawUser `json:"assignees"`
References struct {
Full string `json:"full"`
} `json:"references"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at"`
}
type rawDiscussion struct {
ID string `json:"id"`
IndividualNote bool `json:"individual_note"`
Notes []rawNote `json:"notes"`
}
type rawNote struct {
ID int64 `json:"id"`
Body string `json:"body"`
Type string `json:"type"`
System bool `json:"system"`
Resolvable bool `json:"resolvable"`
Resolved bool `json:"resolved"`
Author rawUser `json:"author"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Position *struct {
NewPath string `json:"new_path"`
OldPath string `json:"old_path"`
NewLine int `json:"new_line"`
OldLine int `json:"old_line"`
} `json:"position"`
}
type rawPipeline struct {
ID int64 `json:"id"`
IID int `json:"iid"`
Status string `json:"status"`
Source string `json:"source"`
Ref string `json:"ref"`
SHA string `json:"sha"`
WebURL string `json:"web_url"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
func convertRawMR(raw *rawMR) *MR {
state := normalizeMRState(raw.State)
namespace, projectPath := splitFullReference(raw.References.Full)
mr := &MR{
ID: raw.ID,
IID: raw.IID,
ProjectID: raw.ProjectID,
Title: raw.Title,
URL: raw.WebURL,
WebURL: raw.WebURL,
State: state,
HeadBranch: raw.SourceBranch,
HeadSHA: raw.SHA,
BaseBranch: raw.TargetBranch,
AuthorUsername: raw.Author.Username,
ProjectNamespace: namespace,
ProjectPath: projectPath,
Body: raw.Description,
Draft: raw.Draft || raw.WorkInProgress,
MergeStatus: raw.MergeStatus,
HasConflicts: raw.HasConflicts,
Reviewers: convertReviewers(raw.Reviewers),
Assignees: convertReviewers(raw.Assignees),
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
MergedAt: raw.MergedAt,
ClosedAt: raw.ClosedAt,
}
return mr
}
func convertRawMRSlice(raw []rawMR) []*MR {
out := make([]*MR, len(raw))
for i := range raw {
out[i] = convertRawMR(&raw[i])
}
return out
}
func convertReviewers(raw []rawUser) []MRReviewer {
out := make([]MRReviewer, 0, len(raw))
for _, r := range raw {
if r.Username == "" {
continue
}
out = append(out, MRReviewer{
Username: r.Username,
Name: r.Name,
Type: "user",
})
}
return out
}
func convertRawIssue(raw *rawIssue) *Issue {
namespace, projectPath := splitFullReference(raw.References.Full)
assignees := make([]string, 0, len(raw.Assignees))
for _, a := range raw.Assignees {
if a.Username != "" {
assignees = append(assignees, a.Username)
}
}
return &Issue{
ID: raw.ID,
IID: raw.IID,
ProjectID: raw.ProjectID,
Title: raw.Title,
Body: raw.Description,
URL: raw.WebURL,
WebURL: raw.WebURL,
State: raw.State,
AuthorUsername: raw.Author.Username,
ProjectNamespace: namespace,
ProjectPath: projectPath,
Labels: append([]string(nil), raw.Labels...),
Assignees: assignees,
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
ClosedAt: raw.ClosedAt,
}
}
func convertRawProject(raw *rawProject) Project {
namespace := raw.Namespace.FullPath
if namespace == "" {
// GitLab subgroups can be arbitrarily nested (acme/team/squad/repo),
// so the namespace is everything up to the final "/" — not just the
// first segment.
if idx := strings.LastIndex(raw.PathWithNamespace, "/"); idx > 0 {
namespace = raw.PathWithNamespace[:idx]
}
}
return Project{
ID: raw.ID,
PathWithNamespace: raw.PathWithNamespace,
Namespace: namespace,
Path: raw.Path,
Name: raw.Name,
Visibility: raw.Visibility,
}
}
func convertRawDiscussion(raw *rawDiscussion) MRDiscussion {
d := MRDiscussion{
ID: raw.ID,
Notes: make([]MRNote, 0, len(raw.Notes)),
}
for i := range raw.Notes {
note := convertRawNote(&raw.Notes[i])
d.Notes = append(d.Notes, note)
if i == 0 {
d.Resolvable = raw.Notes[i].Resolvable
d.Resolved = raw.Notes[i].Resolved
d.CreatedAt = raw.Notes[i].CreatedAt
d.UpdatedAt = raw.Notes[i].UpdatedAt
if raw.Notes[i].Position != nil {
d.Path = raw.Notes[i].Position.NewPath
d.Line = raw.Notes[i].Position.NewLine
d.OldLine = raw.Notes[i].Position.OldLine
}
} else if note.UpdatedAt.After(d.UpdatedAt) {
d.UpdatedAt = note.UpdatedAt
}
}
return d
}
func convertRawNote(raw *rawNote) MRNote {
return MRNote{
ID: raw.ID,
Author: raw.Author.Username,
AuthorAvatar: raw.Author.AvatarURL,
AuthorIsBot: raw.Author.Bot,
Body: raw.Body,
Type: raw.Type,
System: raw.System,
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
}
}
func convertRawPipeline(raw *rawPipeline) Pipeline {
return Pipeline{
ID: raw.ID,
IID: raw.IID,
Status: raw.Status,
Source: raw.Source,
Ref: raw.Ref,
SHA: raw.SHA,
WebURL: raw.WebURL,
StartedAt: raw.StartedAt,
FinishedAt: raw.FinishedAt,
}
}
// mrStateOpen is the normalized "open" state value shared with the GitHub
// integration vocabulary. GitLab's API returns "opened"; we expose "open".
const mrStateOpen = "open"
// normalizeMRState converts GitLab's "opened" to "open" and leaves the rest
// alone so the UI shares the GitHub vocabulary.
func normalizeMRState(state string) string {
switch state {
case gitlabStateOpened:
return mrStateOpen
case gitlabStateMerged, gitlabStateClosed, gitlabStateLocked:
return state
default:
return state
}
}
// splitFullReference parses GitLab's "namespace/path!iid" or
// "namespace/path#iid" form into (namespace, projectPath). It is best-effort:
// when the reference does not match it returns ("", "").
// splitFullReference parses GitLab's full-reference strings (e.g.
// "group/sub/project!42" for an MR or "group/project#10" for an issue) into
// (namespace, projectPath). projectPath is the *full* path-with-namespace
// — "group/sub/project" — so callers can round-trip it back into API URLs
// via projectRef without having to recombine namespace + name themselves.
// namespace is everything before the final "/".
func splitFullReference(full string) (namespace, projectPath string) {
for _, sep := range []string{"!", "#"} {
if idx := strings.Index(full, sep); idx > 0 {
full = full[:idx]
break
}
}
last := strings.LastIndex(full, "/")
if last <= 0 {
return "", ""
}
return full[:last], full
}
func hasOpenDiscussions(discussions []MRDiscussion) bool {
for _, d := range discussions {
if d.Resolvable && !d.Resolved {
return true
}
}
return false
}
func pipelineFailing(pipelines []Pipeline) bool {
state, _, _ := summarizePipelines(pipelines)
return state == pipelineStateFailure
}
// Computed status strings shared by pipeline + approval summarizers.
const statusPending = "pending"
// summarizePipelines reduces a list of pipeline runs (most-recent-first per
// the GitLab API) to a single state plus job counts. Only the most recent
// pipeline matters for the rolled-up status.
func summarizePipelines(pipelines []Pipeline) (state string, jobsTotal, jobsPassing int) {
if len(pipelines) == 0 {
return "", 0, 0
}
latest := pipelines[0]
jobsTotal = latest.JobsTotal
jobsPassing = latest.JobsPassing
switch latest.Status {
case pipelineStatusSuccess:
state = pipelineStatusSuccess
case pipelineStatusFailed, "canceled":
state = pipelineStateFailure
case "skipped":
state = ""
default:
state = statusPending
}
return state, jobsTotal, jobsPassing
}
func summarizeApprovals(have, required int) string {
if required == 0 {
if have > 0 {
return approvalStateApproved
}
return ""
}
if have >= required {
return approvalStateApproved
}
return statusPending
}
// --- Search query builders ---
// buildReviewMRQuery builds a query string for "MRs needing my review".
// GitLab's /merge_requests endpoint scopes to the authenticated user when
// `scope=assigned_to_me` or `reviewer_username=<me>`; we pass
// `reviewer_username=` resolution to the caller via filter (e.g.
// "reviewer_username=octocat"). state defaults to GitLab's opened state.
func buildReviewMRQuery(filter, customQuery string) string {
if customQuery != "" {
return customQuery
}
values := url.Values{}
values.Set("state", gitlabStateOpened)
values.Set("scope", "all")
values.Set("per_page", "50")
if filter != "" {
appendFilter(values, filter)
}
return values.Encode()
}
func buildMRSearchQuery(filter, customQuery string) string {
if customQuery != "" {
return customQuery
}
values := url.Values{}
values.Set("state", gitlabStateOpened)
values.Set("scope", "all")
if filter != "" {
appendFilter(values, filter)
}
return values.Encode()
}
func buildIssueSearchQuery(filter, customQuery string) string {
if customQuery != "" {
return customQuery
}
values := url.Values{}
values.Set("state", gitlabStateOpened)
values.Set("scope", "all")
if filter != "" {
appendFilter(values, filter)
}
return values.Encode()
}
// filterTokenReviewRequested is the /gitlab page tab value that maps to
// GitLab's reviewer_username param. Defined as a constant because it appears
// in multiple spots across the package (translator, MR controller, issues
// controller) and goconst enforces consistency for 3+ occurrences.
const filterTokenReviewRequested = "review_requested"
// userSearchScopeTokens is the set of frontend filter tokens that map
// directly to GitLab's `scope` query param value on /merge_requests and
// /issues. filterTokenReviewRequested is intentionally absent — GitLab has
// no scope=review_requested; that case routes through reviewer_username=<me>
// instead and is handled in translateUserSearchFilter.
var userSearchScopeTokens = map[string]bool{
"assigned_to_me": true,
"created_by_me": true,
}
// translateUserSearchFilter converts the /gitlab page's filter-tab tokens
// into a GitLab API filter string that buildMRSearchQuery /
// buildIssueSearchQuery can splice in via appendFilter. Returns "" when the
// caller should pass the raw filter through unchanged (already in key=value
// form, unknown token, or empty input). The "review_requested" branch needs
// a resolved username because GitLab has no equivalent scope value — the
// controller is responsible for looking the username up and surfacing any
// error before calling here.
func translateUserSearchFilter(token, username string) string {
if token == "" {
return ""
}
if strings.ContainsAny(token, "=&") {
return ""
}
if userSearchScopeTokens[token] {
return "scope=" + token
}
if token == filterTokenReviewRequested {
if username == "" {
return ""
}
return "reviewer_username=" + url.QueryEscape(username) + "&scope=all"
}
return ""
}
// appendFilter parses a `key=value&key2=value2` filter and merges it into
// values. User-supplied keys override defaults set by the caller (so passing
// "state=closed" actually swaps the default "opened" rather than appending
// a second value). Unparseable filters are ignored — callers that need
// stricter validation should use customQuery instead.
func appendFilter(values url.Values, filter string) {
parsed, err := url.ParseQuery(filter)
if err != nil {
return
}
for k, vs := range parsed {
values.Del(k)
for _, v := range vs {
values.Add(k, v)
}
}
}
// countDiffLines returns (additions, deletions) by counting lines starting
// with "+" or "-" (excluding the diff header lines that start with
// "+++"/"---"). Best-effort; matches the GitLab UI's own counting.
func countDiffLines(diff string) (int, int) {
additions, deletions := 0, 0
for _, line := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
continue
case strings.HasPrefix(line, "+"):
additions++
case strings.HasPrefix(line, "-"):
deletions++
}
}
return additions, deletions
}
func diffStatus(newFile, deletedFile, renamedFile bool) string {
switch {
case newFile:
return "added"
case deletedFile:
return "deleted"
case renamedFile:
return "renamed"
default:
return "modified"
}
}