From b35bf33b0e6f28c541319a17cd350de1b909c35e Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Sun, 19 Jul 2026 17:46:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20course=20repo=20overview=20?= =?UTF-8?q?=E2=80=94=20which=20repos=20are=20actually=20generated=20(Miles?= =?UTF-8?q?tone=20F5e,=20Z4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whole-course view of what has been generated: per assignment, how many of its target repos exist in GitLab and who is missing. - gitlab.ExistingRepoNames lists an assignment group's projects in one paginated call (not a GetProject per student), so the overview costs one call per assignment, not students×assignments. - app.CourseRepoOverview resolves each assignment concurrently, matches RepoTargets against the existing set, and returns per-assignment {targets, existing, repos[for, exists], note}. A missing group (never generated) or an abstract/unresolvable assignment becomes a note rather than an error, so one gap does not fail the view. Needs the caller's stored GitLab token. - GraphQL courseRepoOverview(course): [AssignmentRepos]. The pure matcher is unit tested; the GitLab listing is exercised live/by the report path. GUI page follows in the glabs.gui counterpart. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitlab/repos_overview.go | 55 +++ web/app/repos_overview.go | 111 ++++++ web/app/repos_overview_test.go | 44 +++ web/graph/generated/generated.go | 644 +++++++++++++++++++++++++++++++ web/graph/model/models_gen.go | 27 ++ web/graph/repos.graphqls | 33 ++ web/graph/repos.resolvers.go | 36 ++ 7 files changed, 950 insertions(+) create mode 100644 gitlab/repos_overview.go create mode 100644 web/app/repos_overview.go create mode 100644 web/app/repos_overview_test.go create mode 100644 web/graph/repos.graphqls create mode 100644 web/graph/repos.resolvers.go diff --git a/gitlab/repos_overview.go b/gitlab/repos_overview.go new file mode 100644 index 0000000..7d37c02 --- /dev/null +++ b/gitlab/repos_overview.go @@ -0,0 +1,55 @@ +package gitlab + +import ( + "strconv" + + "github.com/obcode/glabs/v3/config" + gitlab "gitlab.com/gitlab-org/api/client-go/v2" +) + +// ExistingRepoNames lists the names of the projects that actually exist in an +// assignment's GitLab group — one paginated group listing rather than a GetProject +// per student, so a whole-course overview stays cheap (one call per assignment, not +// per repository). The caller matches these against RepoTargets to see which repos +// have been generated. A missing group (assignment never generated) surfaces as an +// error the caller can treat as "nothing generated". +func (c *Client) ExistingRepoNames(assignmentCfg *config.AssignmentConfig) (map[string]bool, error) { + groupID, err := c.getGroupID(assignmentCfg) + if err != nil { + return nil, err + } + + names := make(map[string]bool) + opts := &gitlab.ListGroupProjectsOptions{ListOptions: gitlab.ListOptions{PerPage: 100}} + for { + projects, resp, err := c.Groups.ListGroupProjects(groupID, opts) + if err != nil { + return nil, err + } + for _, p := range projects { + names[p.Name] = true + } + next := nextPageFromHeader(resp) + if next == 0 { + break + } + opts.Page = int64(next) + } + return names, nil +} + +// nextPageFromHeader reads GitLab's X-Next-Page pagination header (0 = last page). +func nextPageFromHeader(resp *gitlab.Response) int { + if resp == nil { + return 0 + } + vals := resp.Header["X-Next-Page"] + if len(vals) == 0 || vals[0] == "" { + return 0 + } + n, err := strconv.Atoi(vals[0]) + if err != nil { + return 0 + } + return n +} diff --git a/web/app/repos_overview.go b/web/app/repos_overview.go new file mode 100644 index 0000000..adec9a5 --- /dev/null +++ b/web/app/repos_overview.go @@ -0,0 +1,111 @@ +package app + +import ( + "context" + "sort" + "sync" + + "github.com/obcode/glabs/v3/config" + "github.com/obcode/glabs/v3/reporter" +) + +// repoOverviewConcurrency bounds how many assignments are queried at once. +const repoOverviewConcurrency = 6 + +// RepoStatus is one target repository and whether it actually exists in GitLab. +type RepoStatus struct { + For string + Repo string + URL string + Exists bool +} + +// AssignmentRepos summarises, for one assignment, how many of its target repos have +// been generated. Note carries a reason when the assignment could not be checked +// (abstract/unresolvable, or its GitLab group does not exist yet). +type AssignmentRepos struct { + Name string + Per string + Targets int + Existing int + Repos []RepoStatus + Note string +} + +// CourseRepoOverview reports, per assignment of one of the caller's courses, which +// target repositories actually exist in GitLab — so the owner can see at a glance +// what has been generated and who is missing. It uses one group listing per +// assignment (not a lookup per student) and queries assignments concurrently. It +// needs the caller's stored GitLab token. +func (a *App) CourseRepoOverview(ctx context.Context, course string) ([]*AssignmentRepos, error) { + o, err := owner(ctx) + if err != nil { + return nil, err + } + stored, err := a.Course(ctx, course) + if err != nil { + return nil, err + } + client, err := a.gitlabClientFor(ctx, o, reporter.NewDiscardReporter()) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(stored.Source.Assignments)) + for name := range stored.Source.Assignments { + names = append(names, name) + } + sort.Strings(names) + + out := make([]*AssignmentRepos, len(names)) + sem := make(chan struct{}, repoOverviewConcurrency) + var wg sync.WaitGroup + for i, name := range names { + wg.Add(1) + go func(i int, name string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + ar := &AssignmentRepos{Name: name} + cfg, err := a.resolveAssignmentConfig(ctx, course, name) + if err != nil || cfg == nil { + ar.Note = "abstrakt oder nicht auflösbar — übersprungen" + out[i] = ar + return + } + ar.Per = string(cfg.Per) + targets := cfg.RepoTargets() + ar.Targets = len(targets) + + existing, err := client.ExistingRepoNames(cfg) + if err != nil { + // Most likely the group does not exist yet (never generated); show + // every target as missing rather than failing the whole overview. + ar.Note = "GitLab-Gruppe nicht gefunden — vermutlich noch nicht generiert" + ar.Repos, ar.Existing = matchRepos(targets, nil) + out[i] = ar + return + } + ar.Repos, ar.Existing = matchRepos(targets, existing) + out[i] = ar + }(i, name) + } + wg.Wait() + return out, nil +} + +// matchRepos pairs each target with whether its repo exists, and counts the +// existing ones. A nil existing set means none exist. +func matchRepos(targets []config.RepoTarget, existing map[string]bool) ([]RepoStatus, int) { + repos := make([]RepoStatus, 0, len(targets)) + count := 0 + for _, t := range targets { + ex := existing[t.Repo] + if ex { + count++ + } + repos = append(repos, RepoStatus{For: t.For, Repo: t.Repo, URL: t.URL, Exists: ex}) + } + return repos, count +} diff --git a/web/app/repos_overview_test.go b/web/app/repos_overview_test.go new file mode 100644 index 0000000..69c6bb5 --- /dev/null +++ b/web/app/repos_overview_test.go @@ -0,0 +1,44 @@ +package app + +import ( + "testing" + + "github.com/obcode/glabs/v3/config" +) + +func TestMatchRepos(t *testing.T) { + targets := []config.RepoTarget{ + {For: "a@hm.edu", Repo: "c-b01-a", URL: "https://gl/c-b01-a"}, + {For: "b@hm.edu", Repo: "c-b01-b", URL: "https://gl/c-b01-b"}, + {For: "c@hm.edu", Repo: "c-b01-c", URL: "https://gl/c-b01-c"}, + } + existing := map[string]bool{"c-b01-a": true, "c-b01-c": true} // b is missing + + repos, count := matchRepos(targets, existing) + if count != 2 { + t.Errorf("existing count = %d, want 2", count) + } + if len(repos) != 3 { + t.Fatalf("repos = %d, want 3", len(repos)) + } + got := map[string]bool{} + for _, r := range repos { + got[r.For] = r.Exists + } + if !got["a@hm.edu"] || got["b@hm.edu"] || !got["c@hm.edu"] { + t.Errorf("exists flags wrong: %+v", got) + } +} + +func TestMatchRepos_nilExistingMarksAllMissing(t *testing.T) { + targets := []config.RepoTarget{{For: "a@hm.edu", Repo: "r-a"}, {For: "b@hm.edu", Repo: "r-b"}} + repos, count := matchRepos(targets, nil) + if count != 0 { + t.Errorf("count = %d, want 0 for a nil existing set", count) + } + for _, r := range repos { + if r.Exists { + t.Errorf("%s should be missing when nothing exists", r.For) + } + } +} diff --git a/web/graph/generated/generated.go b/web/graph/generated/generated.go index 3150b2f..f653d0f 100644 --- a/web/graph/generated/generated.go +++ b/web/graph/generated/generated.go @@ -57,6 +57,15 @@ type ComplexityRoot struct { URL func(childComplexity int) int } + AssignmentRepos struct { + Existing func(childComplexity int) int + Name func(childComplexity int) int + Note func(childComplexity int) int + Per func(childComplexity int) int + Repos func(childComplexity int) int + Targets func(childComplexity int) int + } + AssignmentUrls struct { GroupURL func(childComplexity int) int Per func(childComplexity int) int @@ -264,6 +273,7 @@ type ComplexityRoot struct { CourseActivity func(childComplexity int, course string) int CourseCheck func(childComplexity int, name string) int CourseLint func(childComplexity int, name string) int + CourseRepoOverview func(childComplexity int, course string) int CourseStudents func(childComplexity int, course string) int CourseYaml func(childComplexity int, name string) int Courses func(childComplexity int) int @@ -286,6 +296,13 @@ type ComplexityRoot struct { MergeRequest func(childComplexity int) int } + RepoStatus struct { + Exists func(childComplexity int) int + For func(childComplexity int) int + Repo func(childComplexity int) int + URL func(childComplexity int) int + } + RepoUrl struct { For func(childComplexity int) int URL func(childComplexity int) int @@ -387,6 +404,7 @@ type QueryResolver interface { CourseLint(ctx context.Context, name string) ([]*model.Finding, error) GitlabToken(ctx context.Context) (*model.GitLabTokenStatus, error) AssignmentReport(ctx context.Context, course string, name string) (*model.AssignmentReport, error) + CourseRepoOverview(ctx context.Context, course string) ([]*model.AssignmentRepos, error) ScheduledJobs(ctx context.Context, status []model.JobStatus) ([]*model.ScheduledJob, error) ScheduledJob(ctx context.Context, id string) (*model.ScheduledJob, error) CourseStudents(ctx context.Context, course string) ([]*model.CourseStudent, error) @@ -495,6 +513,43 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.AssignmentReport.URL(childComplexity), true + case "AssignmentRepos.existing": + if e.ComplexityRoot.AssignmentRepos.Existing == nil { + break + } + + return e.ComplexityRoot.AssignmentRepos.Existing(childComplexity), true + case "AssignmentRepos.name": + if e.ComplexityRoot.AssignmentRepos.Name == nil { + break + } + + return e.ComplexityRoot.AssignmentRepos.Name(childComplexity), true + case "AssignmentRepos.note": + if e.ComplexityRoot.AssignmentRepos.Note == nil { + break + } + + return e.ComplexityRoot.AssignmentRepos.Note(childComplexity), true + case "AssignmentRepos.per": + if e.ComplexityRoot.AssignmentRepos.Per == nil { + break + } + + return e.ComplexityRoot.AssignmentRepos.Per(childComplexity), true + case "AssignmentRepos.repos": + if e.ComplexityRoot.AssignmentRepos.Repos == nil { + break + } + + return e.ComplexityRoot.AssignmentRepos.Repos(childComplexity), true + case "AssignmentRepos.targets": + if e.ComplexityRoot.AssignmentRepos.Targets == nil { + break + } + + return e.ComplexityRoot.AssignmentRepos.Targets(childComplexity), true + case "AssignmentUrls.groupUrl": if e.ComplexityRoot.AssignmentUrls.GroupURL == nil { break @@ -1443,6 +1498,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Query.CourseLint(childComplexity, args["name"].(string)), true + case "Query.courseRepoOverview": + if e.ComplexityRoot.Query.CourseRepoOverview == nil { + break + } + + args, err := ec.field_Query_courseRepoOverview_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.CourseRepoOverview(childComplexity, args["course"].(string)), true case "Query.courseStudents": if e.ComplexityRoot.Query.CourseStudents == nil { break @@ -1556,6 +1622,31 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ReleaseReport.MergeRequest(childComplexity), true + case "RepoStatus.exists": + if e.ComplexityRoot.RepoStatus.Exists == nil { + break + } + + return e.ComplexityRoot.RepoStatus.Exists(childComplexity), true + case "RepoStatus.for": + if e.ComplexityRoot.RepoStatus.For == nil { + break + } + + return e.ComplexityRoot.RepoStatus.For(childComplexity), true + case "RepoStatus.repo": + if e.ComplexityRoot.RepoStatus.Repo == nil { + break + } + + return e.ComplexityRoot.RepoStatus.Repo(childComplexity), true + case "RepoStatus.url": + if e.ComplexityRoot.RepoStatus.URL == nil { + break + } + + return e.ComplexityRoot.RepoStatus.URL(childComplexity), true + case "RepoUrl.for": if e.ComplexityRoot.RepoUrl.For == nil { break @@ -2458,6 +2549,40 @@ type Subscription { """ assignmentReportProgress(course: String!, name: String!): ReportProgress! } +`, BuiltIn: false}, + {Name: "../repos.graphqls", Input: `"One target repository of an assignment and whether it actually exists in GitLab." +type RepoStatus { + "The student's email (or username/id) or the group's name." + for: String! + "The repository's project name." + repo: String! + "The full web URL of the repository." + url: String! + "Whether the repository has been generated (exists in GitLab)." + exists: Boolean! +} + +""" +How many of an assignment's target repositories have been generated. ` + "`" + `note` + "`" + ` is set +when the assignment could not be checked (abstract/unresolvable, or its GitLab group +does not exist yet — i.e. nothing generated). +""" +type AssignmentRepos { + name: String! + "Whether repos are per student or per group." + per: String! + "How many repositories the assignment targets." + targets: Int! + "How many of them actually exist in GitLab." + existing: Int! + repos: [RepoStatus!]! + note: String +} + +extend type Query { + "Per assignment of one of the caller's courses, which target repositories actually exist in GitLab. Needs a stored GitLab token." + courseRepoOverview(course: String!): [AssignmentRepos!]! +} `, BuiltIn: false}, {Name: "../scheduling.graphqls", Input: `"The lifecycle state of a scheduled job. It starts PENDING, is claimed to RUNNING, and ends in exactly one terminal state." enum JobStatus { @@ -2615,6 +2740,24 @@ func (ec *executionContext) childFields_AssignmentReport(ctx context.Context, fi return nil, fmt.Errorf("no field named %q was found under type AssignmentReport", field.Name) } +func (ec *executionContext) childFields_AssignmentRepos(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext_AssignmentRepos_name(ctx, field) + case "per": + return ec.fieldContext_AssignmentRepos_per(ctx, field) + case "targets": + return ec.fieldContext_AssignmentRepos_targets(ctx, field) + case "existing": + return ec.fieldContext_AssignmentRepos_existing(ctx, field) + case "repos": + return ec.fieldContext_AssignmentRepos_repos(ctx, field) + case "note": + return ec.fieldContext_AssignmentRepos_note(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AssignmentRepos", field.Name) +} + func (ec *executionContext) childFields_AssignmentUrls(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "per": @@ -2985,6 +3128,20 @@ func (ec *executionContext) childFields_ReleaseReport(ctx context.Context, field return nil, fmt.Errorf("no field named %q was found under type ReleaseReport", field.Name) } +func (ec *executionContext) childFields_RepoStatus(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "for": + return ec.fieldContext_RepoStatus_for(ctx, field) + case "repo": + return ec.fieldContext_RepoStatus_repo(ctx, field) + case "url": + return ec.fieldContext_RepoStatus_url(ctx, field) + case "exists": + return ec.fieldContext_RepoStatus_exists(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type RepoStatus", field.Name) +} + func (ec *executionContext) childFields_RepoUrl(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "for": @@ -3781,6 +3938,20 @@ func (ec *executionContext) field_Query_courseLint_args(ctx context.Context, raw return args, nil } +func (ec *executionContext) field_Query_courseRepoOverview_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "course", + func(ctx context.Context, v any) (string, error) { + return ec.unmarshalNString2string(ctx, v) + }) + if err != nil { + return nil, err + } + args["course"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_courseStudents_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4307,6 +4478,153 @@ func (ec *executionContext) fieldContext_AssignmentReport_projects(_ context.Con return fc, nil } +func (ec *executionContext) _AssignmentRepos_name(ctx context.Context, field graphql.CollectedField, obj *model.AssignmentRepos) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_AssignmentRepos_name(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_AssignmentRepos_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("AssignmentRepos", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _AssignmentRepos_per(ctx context.Context, field graphql.CollectedField, obj *model.AssignmentRepos) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_AssignmentRepos_per(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Per, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_AssignmentRepos_per(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("AssignmentRepos", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _AssignmentRepos_targets(ctx context.Context, field graphql.CollectedField, obj *model.AssignmentRepos) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_AssignmentRepos_targets(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Targets, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_AssignmentRepos_targets(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("AssignmentRepos", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _AssignmentRepos_existing(ctx context.Context, field graphql.CollectedField, obj *model.AssignmentRepos) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_AssignmentRepos_existing(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Existing, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_AssignmentRepos_existing(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("AssignmentRepos", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _AssignmentRepos_repos(ctx context.Context, field graphql.CollectedField, obj *model.AssignmentRepos) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_AssignmentRepos_repos(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Repos, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*model.RepoStatus) graphql.Marshaler { + return ec.marshalNRepoStatus2ᚕᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐRepoStatusᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_AssignmentRepos_repos(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AssignmentRepos", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_RepoStatus(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _AssignmentRepos_note(ctx context.Context, field graphql.CollectedField, obj *model.AssignmentRepos) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_AssignmentRepos_note(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Note, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2ᚖstring(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_AssignmentRepos_note(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("AssignmentRepos", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _AssignmentUrls_per(ctx context.Context, field graphql.CollectedField, obj *model.AssignmentUrls) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -8280,6 +8598,50 @@ func (ec *executionContext) fieldContext_Query_assignmentReport(ctx context.Cont return fc, nil } +func (ec *executionContext) _Query_courseRepoOverview(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_courseRepoOverview(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().CourseRepoOverview(ctx, fc.Args["course"].(string)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*model.AssignmentRepos) graphql.Marshaler { + return ec.marshalNAssignmentRepos2ᚕᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐAssignmentReposᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_courseRepoOverview(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_AssignmentRepos(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_courseRepoOverview_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query_scheduledJobs(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -8621,6 +8983,98 @@ func (ec *executionContext) fieldContext_ReleaseReport_dockerImages(_ context.Co return fc, nil } +func (ec *executionContext) _RepoStatus_for(ctx context.Context, field graphql.CollectedField, obj *model.RepoStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_RepoStatus_for(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.For, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_RepoStatus_for(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("RepoStatus", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _RepoStatus_repo(ctx context.Context, field graphql.CollectedField, obj *model.RepoStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_RepoStatus_repo(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Repo, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_RepoStatus_repo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("RepoStatus", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _RepoStatus_url(ctx context.Context, field graphql.CollectedField, obj *model.RepoStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_RepoStatus_url(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.URL, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_RepoStatus_url(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("RepoStatus", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _RepoStatus_exists(ctx context.Context, field graphql.CollectedField, obj *model.RepoStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_RepoStatus_exists(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Exists, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_RepoStatus_exists(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("RepoStatus", field, false, false, errors.New("field of type Boolean does not have child fields")) +} + func (ec *executionContext) _RepoUrl_for(ctx context.Context, field graphql.CollectedField, obj *model.RepoURL) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10807,6 +11261,69 @@ func (ec *executionContext) _AssignmentReport(ctx context.Context, sel ast.Selec return out } +var assignmentReposImplementors = []string{"AssignmentRepos"} + +func (ec *executionContext) _AssignmentRepos(ctx context.Context, sel ast.SelectionSet, obj *model.AssignmentRepos) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, assignmentReposImplementors) + + out := graphql.NewFieldSet(fields) + deferredFieldSet := graphql.NewFieldSet(nil) + deferLabelToView := make(map[string]*graphql.FieldSetView) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AssignmentRepos") + case "name": + out.Values[i] = ec._AssignmentRepos_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "per": + out.Values[i] = ec._AssignmentRepos_per(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "targets": + out.Values[i] = ec._AssignmentRepos_targets(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "existing": + out.Values[i] = ec._AssignmentRepos_existing(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "repos": + out.Values[i] = ec._AssignmentRepos_repos(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "note": + out.Values[i] = ec._AssignmentRepos_note(ctx, field, obj) + if out.Values[i] == graphql.RequiredNull { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferLabelToView), math.MaxInt32))) + + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Defers: deferLabelToView, + Path: graphql.GetPath(ctx), + FieldSet: deferredFieldSet, + Context: ctx, + }) + + return out +} + var assignmentUrlsImplementors = []string{"AssignmentUrls"} func (ec *executionContext) _AssignmentUrls(ctx context.Context, sel ast.SelectionSet, obj *model.AssignmentUrls) graphql.Marshaler { @@ -12666,6 +13183,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "courseRepoOverview": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_courseRepoOverview(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "scheduledJobs": field := field @@ -12859,6 +13398,59 @@ func (ec *executionContext) _ReleaseReport(ctx context.Context, sel ast.Selectio return out } +var repoStatusImplementors = []string{"RepoStatus"} + +func (ec *executionContext) _RepoStatus(ctx context.Context, sel ast.SelectionSet, obj *model.RepoStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, repoStatusImplementors) + + out := graphql.NewFieldSet(fields) + deferredFieldSet := graphql.NewFieldSet(nil) + deferLabelToView := make(map[string]*graphql.FieldSetView) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("RepoStatus") + case "for": + out.Values[i] = ec._RepoStatus_for(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "repo": + out.Values[i] = ec._RepoStatus_repo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "url": + out.Values[i] = ec._RepoStatus_url(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "exists": + out.Values[i] = ec._RepoStatus_exists(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferLabelToView), math.MaxInt32))) + + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Defers: deferLabelToView, + Path: graphql.GetPath(ctx), + FieldSet: deferredFieldSet, + Context: ctx, + }) + + return out +} + var repoUrlImplementors = []string{"RepoUrl"} func (ec *executionContext) _RepoUrl(ctx context.Context, sel ast.SelectionSet, obj *model.RepoURL) graphql.Marshaler { @@ -13687,6 +14279,32 @@ func (ec *executionContext) marshalNActivityEntry2ᚖgithubᚗcomᚋobcodeᚋgla return ec._ActivityEntry(ctx, sel, v) } +func (ec *executionContext) marshalNAssignmentRepos2ᚕᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐAssignmentReposᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.AssignmentRepos) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNAssignmentRepos2ᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐAssignmentRepos(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNAssignmentRepos2ᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐAssignmentRepos(ctx context.Context, sel ast.SelectionSet, v *model.AssignmentRepos) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AssignmentRepos(ctx, sel, v) +} + func (ec *executionContext) marshalNAssignmentView2githubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐAssignmentView(ctx context.Context, sel ast.SelectionSet, v model.AssignmentView) graphql.Marshaler { return ec._AssignmentView(ctx, sel, &v) } @@ -14261,6 +14879,32 @@ func (ec *executionContext) marshalNProjectReport2ᚖgithubᚗcomᚋobcodeᚋgla return ec._ProjectReport(ctx, sel, v) } +func (ec *executionContext) marshalNRepoStatus2ᚕᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐRepoStatusᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.RepoStatus) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNRepoStatus2ᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐRepoStatus(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNRepoStatus2ᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐRepoStatus(ctx context.Context, sel ast.SelectionSet, v *model.RepoStatus) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._RepoStatus(ctx, sel, v) +} + func (ec *executionContext) marshalNRepoUrl2ᚕᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐRepoURLᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.RepoURL) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) diff --git a/web/graph/model/models_gen.go b/web/graph/model/models_gen.go index a0e981d..94c16e0 100644 --- a/web/graph/model/models_gen.go +++ b/web/graph/model/models_gen.go @@ -46,6 +46,21 @@ type AssignmentReport struct { Projects []*ProjectReport `json:"projects"` } +// How many of an assignment's target repositories have been generated. `note` is set +// when the assignment could not be checked (abstract/unresolvable, or its GitLab group +// does not exist yet — i.e. nothing generated). +type AssignmentRepos struct { + Name string `json:"name"` + // Whether repos are per student or per group. + Per string `json:"per"` + // How many repositories the assignment targets. + Targets int `json:"targets"` + // How many of them actually exist in GitLab. + Existing int `json:"existing"` + Repos []*RepoStatus `json:"repos"` + Note *string `json:"note,omitempty"` +} + // The repository URLs for one assignment: the assignment-level group URL plus one // URL per student or per group. Read-only and derived purely from the resolved // configuration — no GitLab token or API call is involved. @@ -342,6 +357,18 @@ type ReleaseReport struct { DockerImages *DockerImagesReport `json:"dockerImages,omitempty"` } +// One target repository of an assignment and whether it actually exists in GitLab. +type RepoStatus struct { + // The student's email (or username/id) or the group's name. + For string `json:"for"` + // The repository's project name. + Repo string `json:"repo"` + // The full web URL of the repository. + URL string `json:"url"` + // Whether the repository has been generated (exists in GitLab). + Exists bool `json:"exists"` +} + // One repository URL together with who it belongs to. type RepoURL struct { // The student's email (or username/id fallback) or the group's name. diff --git a/web/graph/repos.graphqls b/web/graph/repos.graphqls new file mode 100644 index 0000000..563f4d2 --- /dev/null +++ b/web/graph/repos.graphqls @@ -0,0 +1,33 @@ +"One target repository of an assignment and whether it actually exists in GitLab." +type RepoStatus { + "The student's email (or username/id) or the group's name." + for: String! + "The repository's project name." + repo: String! + "The full web URL of the repository." + url: String! + "Whether the repository has been generated (exists in GitLab)." + exists: Boolean! +} + +""" +How many of an assignment's target repositories have been generated. `note` is set +when the assignment could not be checked (abstract/unresolvable, or its GitLab group +does not exist yet — i.e. nothing generated). +""" +type AssignmentRepos { + name: String! + "Whether repos are per student or per group." + per: String! + "How many repositories the assignment targets." + targets: Int! + "How many of them actually exist in GitLab." + existing: Int! + repos: [RepoStatus!]! + note: String +} + +extend type Query { + "Per assignment of one of the caller's courses, which target repositories actually exist in GitLab. Needs a stored GitLab token." + courseRepoOverview(course: String!): [AssignmentRepos!]! +} diff --git a/web/graph/repos.resolvers.go b/web/graph/repos.resolvers.go new file mode 100644 index 0000000..14cf287 --- /dev/null +++ b/web/graph/repos.resolvers.go @@ -0,0 +1,36 @@ +package graph + +// This file will be automatically regenerated based on the schema, any resolver +// implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.94 + +import ( + "context" + + "github.com/obcode/glabs/v3/web/graph/model" +) + +// CourseRepoOverview is the resolver for the courseRepoOverview field. +func (r *queryResolver) CourseRepoOverview(ctx context.Context, course string) ([]*model.AssignmentRepos, error) { + overview, err := r.app.CourseRepoOverview(ctx, course) + if err != nil { + return nil, err + } + out := make([]*model.AssignmentRepos, 0, len(overview)) + for _, a := range overview { + repos := make([]*model.RepoStatus, 0, len(a.Repos)) + for _, r := range a.Repos { + repos = append(repos, &model.RepoStatus{For: r.For, Repo: r.Repo, URL: r.URL, Exists: r.Exists}) + } + out = append(out, &model.AssignmentRepos{ + Name: a.Name, + Per: a.Per, + Targets: a.Targets, + Existing: a.Existing, + Repos: repos, + Note: emptyToNil(a.Note), + }) + } + return out, nil +}