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
7 changes: 7 additions & 0 deletions gitlab/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import (
r "github.com/obcode/glabs/v3/gitlab/report"
)

// ReportData returns the structured report for an assignment without rendering it
// to text/HTML. It is the data-returning entry point the web server uses; the
// CLI's Report/ReportHTML render this same data.
func (c *Client) ReportData(assignmentCfg *config.AssignmentConfig) (*r.Reports, error) {
return c.report(assignmentCfg)
}

func (c *Client) Report(assignmentCfg *config.AssignmentConfig, templateFile *string, output *string) error {
report, err := c.report(assignmentCfg)
if err != nil {
Expand Down
60 changes: 60 additions & 0 deletions web/app/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package app

import (
"context"
"fmt"

"github.com/obcode/glabs/v3/gitlab"
"github.com/obcode/glabs/v3/gitlab/report"
"github.com/obcode/glabs/v3/reporter"
)

// gitlabClientFor builds a GitLab client from the caller's stored PAT. This is the
// single place in the web server where the plaintext token exists: it is
// decrypted here, handed straight to the client, and never logged or returned. A
// discard reporter keeps the client from writing progress to the server's stdout.
func (a *App) gitlabClientFor(ctx context.Context, owner string) (*gitlab.Client, error) {
if a.sealer == nil {
return nil, fmt.Errorf("secret storage is unavailable: set secrets.key in the server config")
}
sec, err := a.db.GetUserSecret(ctx, owner)
if err != nil {
return nil, err
}
if sec == nil || sec.GitLab == nil {
return nil, fmt.Errorf("no GitLab token stored — add one under GitLab-Token first")
}
token, err := a.sealer.Open(*sec.GitLab)
if err != nil {
return nil, err
}
return gitlab.NewClient(
gitlab.WithHost(a.gitlabHost),
gitlab.WithToken(token),
gitlab.WithReporter(reporter.NewDiscardReporter()),
)
}

// AssignmentReport fetches a live report over the repositories of one assignment
// of one of the caller's courses, using the caller's stored GitLab token. It
// returns nil (no error) when there is no such assignment or it cannot be
// resolved (e.g. an abstract base); it errors when no token is stored or GitLab
// is unreachable.
func (a *App) AssignmentReport(ctx context.Context, course, name string) (*report.Reports, error) {
o, err := owner(ctx)
if err != nil {
return nil, err
}
cfg, err := a.resolveAssignmentConfig(ctx, course, name)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
client, err := a.gitlabClientFor(ctx, o)
if err != nil {
return nil, err
}
return client.ReportData(cfg)
}
69 changes: 69 additions & 0 deletions web/app/report_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package app

import (
"strings"
"testing"
)

func TestAssignmentReport_noTokenStoredReturnsError(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse)
a := &App{db: fs, gitlabHost: "https://gitlab.example", sealer: testSealer(t)}

// The assignment resolves, but no GitLab token is stored → a clear error.
_, err := a.AssignmentReport(ctxAs(owner), "uc", "blatt1")
if err == nil {
t.Fatal("expected an error when no token is stored")
}
if !strings.Contains(err.Error(), "no GitLab token") {
t.Errorf("error = %q, want it to mention the missing token", err)
}
}

func TestAssignmentReport_noSealerReturnsError(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse)
a := &App{db: fs, gitlabHost: "https://gitlab.example", sealer: nil}

_, err := a.AssignmentReport(ctxAs(owner), "uc", "blatt1")
if err == nil {
t.Fatal("expected an error when secret storage is unavailable")
}
if !strings.Contains(err.Error(), "secret storage is unavailable") {
t.Errorf("error = %q, want it to mention unavailable secret storage", err)
}
}

func TestAssignmentReport_missingAssignmentReturnsNil(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse)
a := &App{db: fs, gitlabHost: "https://gitlab.example", sealer: testSealer(t)}

// No such assignment → nil, no error (and no token needed).
rep, err := a.AssignmentReport(ctxAs(owner), "uc", "nope")
if err != nil {
t.Fatalf("AssignmentReport: %v", err)
}
if rep != nil {
t.Errorf("expected nil for a missing assignment, got %+v", rep)
}
}

func TestAssignmentReport_abstractBaseReturnsNil(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/tc"] = storedCourse(t, owner, tcCourse)
a := &App{db: fs, gitlabHost: "https://gl", sealer: testSealer(t)}

// An abstract base does not resolve → nil, no error (token never consulted).
rep, err := a.AssignmentReport(ctxAs(owner), "tc", "base")
if err != nil {
t.Fatalf("AssignmentReport: %v", err)
}
if rep != nil {
t.Errorf("expected nil for an abstract base, got %+v", rep)
}
}
32 changes: 22 additions & 10 deletions web/app/urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ type AssignmentURLs struct {
// in both cases there simply are no URLs. ErrCourseNotFound propagates when the
// course itself is not the caller's.
func (a *App) AssignmentURLs(ctx context.Context, course, name string) (*AssignmentURLs, error) {
cfg, err := a.resolveAssignmentConfig(ctx, course, name)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
return &AssignmentURLs{
Per: string(cfg.Per),
GroupURL: cfg.URL,
Repos: cfg.RepoURLs(),
}, nil
}

// resolveAssignmentConfig resolves one assignment of one of the caller's courses
// to a full AssignmentConfig (URL, roster, everything), from the stored bytes so
// the result matches the CLI exactly. It returns nil (no error) when the course
// has no such assignment or the assignment cannot be resolved (e.g. an abstract
// base). ErrCourseNotFound propagates when the course is not the caller's.
func (a *App) resolveAssignmentConfig(ctx context.Context, course, name string) (*config.AssignmentConfig, error) {
stored, err := a.Course(ctx, course)
if err != nil {
return nil, err
Expand All @@ -34,9 +54,6 @@ func (a *App) AssignmentURLs(ctx context.Context, course, name string) (*Assignm
return nil, nil
}

// Resolve from the stored bytes so the URLs match the CLI exactly (the same
// inheritance, roster resolution and path normalization). Fall back to
// re-encoding the source if the raw bytes are absent.
bytes := stored.RawYAML
if len(bytes) == 0 {
if encoded, err := config.EncodeCourse(stored.Source); err == nil {
Expand All @@ -45,13 +62,8 @@ func (a *App) AssignmentURLs(ctx context.Context, course, name string) (*Assignm
}
cfg, err := config.ResolveAssignmentFromBytes(bytes, course, name, config.Globals{GitlabHost: a.gitlabHost})
if err != nil {
// Not resolvable (abstract base, missing parent, cycle) → no URLs.
// Not resolvable (abstract base, missing parent, cycle).
return nil, nil
}

return &AssignmentURLs{
Per: string(cfg.Per),
GroupURL: cfg.URL,
Repos: cfg.RepoURLs(),
}, nil
return cfg, nil
}
Loading
Loading