qa: Can export to gitlab
This commit is contained in:
parent
0a22d09947
commit
60a34d3ced
@ -20,6 +20,7 @@ COPY settings settings/
|
||||
COPY libfic ./libfic/
|
||||
COPY --from=nodebuild /ui ./qa/ui
|
||||
COPY qa ./qa/
|
||||
COPY admin ./admin/
|
||||
|
||||
RUN go get -d -v ./qa && \
|
||||
go build -v -buildvcs=false -o qa/qa ./qa
|
||||
|
97
qa/api/admin.go
Normal file
97
qa/api/admin.go
Normal 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)
|
||||
}
|
@ -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
293
qa/api/gitlab.go
Normal 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)
|
||||
}
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -22,8 +22,13 @@ func NewApp(baseURL string) App {
|
||||
gin.ForceConsoleColor()
|
||||
router := gin.Default()
|
||||
|
||||
api.InitializeGitLabOIDC(baseURL)
|
||||
|
||||
store := memstore.NewStore([]byte("secret"))
|
||||
router.Use(sessions.Sessions("qa-session", store))
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("baseurl", baseURL)
|
||||
})
|
||||
|
||||
api.DeclareRoutes(router.Group(""))
|
||||
|
||||
|
@ -48,5 +48,8 @@
|
||||
<a href="themes/{exercice.id_theme}" title={$themesIdx[exercice.id_theme].authors}>{$themesIdx[exercice.id_theme].name}</a>
|
||||
</small>
|
||||
<a href="../{$themesIdx[exercice.id_theme].urlid}/{exercice.urlid}" target="_self" class="float-right ml-2 btn btn-sm btn-info"><Icon name="play-fill" /> Site du challenge</a>
|
||||
{#if exercice.forge_link}
|
||||
<a href="{exercice.forge_link}" target="_blank" class="float-right ml-2 btn btn-sm btn-warning"><Icon name="gitlab" /> GitLab</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</h2>
|
||||
|
@ -33,6 +33,17 @@
|
||||
activemenu = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function rungitlab() {
|
||||
const res = await fetch('api/gitlab/token');
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
const gitlab = rungitlab();
|
||||
</script>
|
||||
|
||||
<Navbar color="dark" dark expand="xs">
|
||||
@ -90,6 +101,17 @@
|
||||
{/if}
|
||||
</Nav>
|
||||
<Nav class="ms-auto text-light" navbar>
|
||||
{#await gitlab then gl}
|
||||
<Icon name="gitlab" />
|
||||
{:catch err}
|
||||
<Button
|
||||
color="warning"
|
||||
size="sm"
|
||||
href="auth/gitlab?next=/"
|
||||
>
|
||||
<Icon name="gitlab" /> Connexion GitLab
|
||||
</Button>
|
||||
{/await}
|
||||
<NavItem class="ms-2 text-truncate">
|
||||
v{$version.version}
|
||||
{#if $auth}– Logged as {$auth.name} (team #{$auth.id_team}){/if}
|
||||
|
@ -149,6 +149,17 @@
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function rungitlab() {
|
||||
const res = await fetch('api/gitlab/token');
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
const gitlab = rungitlab();
|
||||
</script>
|
||||
|
||||
{#if query}
|
||||
@ -157,6 +168,16 @@
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h4 class="card-title fw-bold mb-0">{query.subject}</h4>
|
||||
<div>
|
||||
{#if $auth && !query.solved}
|
||||
{#await gitlab then gl}
|
||||
<Button
|
||||
on:click={() => query.export2Gitlab()}
|
||||
>
|
||||
<Icon name="gitlab" />
|
||||
Exporter vers GitLab
|
||||
</Button>
|
||||
{/await}
|
||||
{/if}
|
||||
{#if $auth && !query.solved && $viewIdx[query.id_exercice]}
|
||||
<Button on:click={solveQA} color="success">
|
||||
<Icon name="check" />
|
||||
|
@ -7,7 +7,7 @@ export class Exercice {
|
||||
}
|
||||
}
|
||||
|
||||
update({ id, id_theme, title, wip, urlid, path, statement, overview, headline, finished, issue, issuekind, depend, gain, coefficient, videoURI, resolution, seealso }) {
|
||||
update({ id, id_theme, title, wip, urlid, path, statement, overview, headline, finished, issue, issuekind, depend, gain, coefficient, videoURI, resolution, seealso, forge_link }) {
|
||||
this.id = id;
|
||||
this.id_theme = id_theme;
|
||||
this.title = title;
|
||||
@ -26,6 +26,7 @@ export class Exercice {
|
||||
this.videoURI = videoURI;
|
||||
this.resolution = resolution;
|
||||
this.seealso = seealso;
|
||||
this.forge_link = forge_link;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,6 +58,15 @@ export class QAQuery {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
async export2Gitlab() {
|
||||
const res = await fetch(`api/qa/${this.id}/gitlab_export`, {method: 'POST', headers: {'Accept': 'application/json'}})
|
||||
if (res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExerciceQA(eid) {
|
||||
|
Loading…
Reference in New Issue
Block a user