qa: Can export to gitlab

This commit is contained in:
nemunaire 2023-11-25 13:43:41 +01:00
commit 60a34d3ced
13 changed files with 463 additions and 3 deletions

97
qa/api/admin.go Normal file
View file

@ -0,0 +1,97 @@
package api
import (
"encoding/json"
"flag"
"io"
"net/http"
"net/url"
"path"
"github.com/gin-gonic/gin"
)
var adminLink string
func init() {
flag.StringVar(&adminLink, "admin-link", adminLink, "URL to admin interface, to use its features as replacement")
}
func fwdAdmin(method, urlpath string, body io.Reader, out interface{}) error {
u, err := url.Parse(adminLink)
if err != nil {
return err
}
var user, pass string
if u.User != nil {
user = u.User.Username()
pass, _ = u.User.Password()
u.User = nil
}
u.Path = path.Join(u.Path, urlpath)
r, err := http.NewRequest(method, u.String(), body)
if err != nil {
return err
}
if len(user) != 0 || len(pass) != 0 {
r.SetBasicAuth(user, pass)
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
return err
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
return dec.Decode(&out)
}
func fwdAdminRequest(c *gin.Context, urlpath string) {
u, err := url.Parse(adminLink)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
var user, pass string
if u.User != nil {
user = u.User.Username()
pass, _ = u.User.Password()
u.User = nil
}
if len(urlpath) > 0 {
u.Path = path.Join(u.Path, urlpath)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
}
r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
if len(user) != 0 || len(pass) != 0 {
r.SetBasicAuth(user, pass)
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"errmsg": err.Error()})
return
}
defer resp.Body.Close()
headers := map[string]string{}
for key := range resp.Header {
headers[key] = resp.Header.Get(key)
}
c.DataFromReader(resp.StatusCode, resp.ContentLength, resp.Header.Get("Content-Type"), resp.Body, headers)
}

View file

@ -63,5 +63,10 @@ func listExercices(c *gin.Context) {
}
func showExercice(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("exercice"))
if adminLink == "" {
c.JSON(http.StatusOK, c.MustGet("exercice"))
return
}
fwdAdminRequest(c, fmt.Sprintf("/api/exercices/%s", string(c.Param("eid"))))
}

293
qa/api/gitlab.go Normal file
View file

@ -0,0 +1,293 @@
package api
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
adminapi "srs.epita.fr/fic-server/admin/api"
"srs.epita.fr/fic-server/libfic"
)
var (
gitlabBaseURL = "https://gitlab.cri.epita.fr"
gitlabClientID = ""
gitlabSecret = ""
gitlaboauth2Config *oauth2.Config
oidcRedirectURL = "https://fic.srs.epita.fr"
)
func init() {
flag.StringVar(&oidcRedirectURL, "oidc-redirect", oidcRedirectURL, "Base URL for the redirect after connection")
flag.StringVar(&gitlabBaseURL, "gitlab-baseurl", gitlabBaseURL, "Base URL of the Gitlab instance")
flag.StringVar(&gitlabClientID, "gitlab-clientid", gitlabClientID, "ClientID for GitLab's OIDC")
flag.StringVar(&gitlabSecret, "gitlab-secret", gitlabSecret, "Secret for GitLab's OIDC")
}
func InitializeGitLabOIDC(baseURL string) {
if gitlabClientID != "" && gitlabSecret != "" {
gitlaboauth2Config = &oauth2.Config{
ClientID: gitlabClientID,
ClientSecret: gitlabSecret,
RedirectURL: oidcRedirectURL + baseURL + "/callback/gitlab/complete",
// Discovery returns the OAuth2 endpoints.
Endpoint: oauth2.Endpoint{
AuthURL: gitlabBaseURL + "/oauth/authorize",
TokenURL: gitlabBaseURL + "/oauth/token",
},
// "openid" is a required scope for OpenID Connect flows.
Scopes: []string{"api", "email"},
}
}
}
func declareGitlabRoutes(router *gin.RouterGroup, authrouter *gin.RouterGroup) {
if gitlaboauth2Config != nil {
router.GET("/auth/gitlab", redirectOAuth_GitLab)
router.GET("/callback/gitlab/complete", GitLab_OAuth_complete)
tokenRoutes := authrouter.Group("")
tokenRoutes.Use(gitlabHandler)
tokenRoutes.GET("/gitlab/token", GitLab_GetMyToken)
}
}
func declareGitlabExportRoutes(router *gin.RouterGroup) {
if gitlaboauth2Config != nil {
tokenRoutes := router.Group("")
tokenRoutes.Use(gitlabHandler)
if adminLink == "" {
log.Println("-admin-link should be defined to be able to export to GitLab!")
} else {
tokenRoutes.POST("/gitlab_export", GitLab_ExportQA)
}
}
}
func gitlabHandler(c *gin.Context) {
session := sessions.Default(c)
var oauth2Token *oauth2.Token
if tok, ok := session.Get("gitlab-token").([]byte); ok {
err := json.Unmarshal(tok, &oauth2Token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
} else {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "No token defined"})
return
}
err := prepareOAuth2Token(c.Request.Context(), oauth2Token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.Set("gitlab-token", oauth2Token)
c.Next()
}
func prepareOAuth2Token(ctx context.Context, tk *oauth2.Token) error {
tsource := oauth2.ReuseTokenSource(tk, gitlaboauth2Config.TokenSource(ctx, tk))
_, err := tsource.Token()
if err != nil {
log.Println("Unable to regenerate GitLab token:", err)
}
return err
}
func redirectOAuth_GitLab(c *gin.Context) {
session := sessions.Default(c)
// Save next parameter
if len(c.Request.URL.Query().Get("next")) > 0 {
session.Set("gitlab-oidc-source", c.Request.URL.Query().Get("next"))
}
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
session.Set("oidc-state", b)
session.Save()
c.Redirect(http.StatusFound, gitlaboauth2Config.AuthCodeURL(hex.EncodeToString(b)))
}
func GitLab_OAuth_complete(c *gin.Context) {
session := sessions.Default(c)
state, err := hex.DecodeString(c.Request.URL.Query().Get("state"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if session_state, ok := session.Get("oidc-state").([]byte); !ok {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No OIDC session in progress."})
return
} else if !bytes.Equal(state, session_state) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad session state."})
return
}
oauth2Token, err := gitlaboauth2Config.Exchange(c.Request.Context(), c.Request.URL.Query().Get("code"))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Failed to exchange token: " + err.Error()})
return
}
jsonToken, err := json.Marshal(oauth2Token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
}
session.Set("gitlab-token", jsonToken)
prepareOAuth2Token(context.Background(), oauth2Token)
session.Save()
log.Println("New GitLab OAuth2 session opened")
baseURL := c.MustGet("baseurl").(string)
if source, ok := session.Get("gitlab-oidc-source").(string); ok {
session.Delete("gitlab-oidc-source")
c.Redirect(http.StatusFound, baseURL+source)
} else {
c.Redirect(http.StatusFound, baseURL)
}
}
func GitLab_GetMyToken(c *gin.Context) {
oauth2Token := c.MustGet("gitlab-token").(*oauth2.Token)
c.JSON(http.StatusOK, oauth2Token)
}
type GitLabIssue struct {
Title string
Description string
}
func gitlab_newIssue(ctx context.Context, token *oauth2.Token, exerciceid string, issue *GitLabIssue) error {
client := gitlaboauth2Config.Client(ctx, token)
params := url.Values{}
params.Set("title", "[QA] "+issue.Title)
params.Set("description", issue.Description)
enc, err := json.Marshal(issue)
if err != nil {
return err
}
req, err := http.NewRequest("POST", gitlabBaseURL+"/api/v4/projects/"+url.QueryEscape(exerciceid)+"/issues?"+params.Encode(), bytes.NewBuffer(enc))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
str, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Bad status code from the API: %d %s", resp.StatusCode, string(str))
}
return nil
}
func GitLab_getExerciceId(exercice *fic.Exercice) (string, error) {
// Get the forge link
var adminEx adminapi.Exercice
err := fwdAdmin("GET", fmt.Sprintf("/api/exercices/%d", exercice.Id), nil, &adminEx)
if err != nil {
return "", err
}
forgelink, err := url.Parse(adminEx.ForgeLink)
if err != nil {
return "", fmt.Errorf("forge_link not found")
}
forgelink.Path = forgelink.Path[:strings.Index(forgelink.Path, "/-/tree/")]
return strings.TrimPrefix(forgelink.Path, "/"), nil
}
func GitLab_ExportQA(c *gin.Context) {
query := c.MustGet("qa").(*fic.QAQuery)
exercice, err := fic.GetExercice(query.IdExercice)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
gitlabid, err := GitLab_getExerciceId(exercice)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
comments, err := query.GetComments()
if err != nil {
log.Println("Unable to GetComments: ", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to list comments: %s", err.Error())})
return
}
description := "<" + path.Join(oidcRedirectURL, "exercices", fmt.Sprintf("%d", exercice.Id), fmt.Sprintf("%d", query.Id)) + ">"
if len(comments) > 0 {
for i, comment := range comments {
if i > 0 {
description += "\n\n---"
}
description += fmt.Sprintf("\n\n%s\n\nPar %s, le %s", comment.Content, comment.User, comment.Date.Format(time.UnixDate))
}
}
// Format the issue
issue := GitLabIssue{
Title: query.Subject,
Description: description,
}
// Create the issue on GitLab
oauth2Token := c.MustGet("gitlab-token").(*oauth2.Token)
err = gitlab_newIssue(c.Request.Context(), oauth2Token, gitlabid, &issue)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, gitlabid)
}

View file

@ -27,6 +27,8 @@ func declareQARoutes(router *gin.RouterGroup) {
qaRoutes.GET("comments", getQAComments)
qaRoutes.POST("comments", createQAComment)
declareGitlabExportRoutes(qaRoutes)
commentsRoutes := qaRoutes.Group("comments/:cid")
commentsRoutes.Use(qaCommentHandler)
commentsRoutes.DELETE("", deleteQAComment)

View file

@ -15,6 +15,7 @@ func DeclareRoutes(router *gin.RouterGroup) {
declareThemesRoutes(apiRoutes)
declareTodoRoutes(apiRoutes)
declareVersionRoutes(apiRoutes)
declareGitlabRoutes(router, apiRoutes)
apiManagerRoutes := router.Group("/api")
apiManagerRoutes.Use(authMiddleware(func(ficteam string, teamid int64, c *gin.Context) bool {

View file

@ -24,7 +24,7 @@ func showVersion(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"version": 0.2,
"version": 0.3,
"auth": map[string]interface{}{
"name": ficteam,
"id_team": teamid,