Skip to content

Commit 6d7a900

Browse files
Merge branch 'main' into fix/config-ui-dev-api-proxy
2 parents 977ee38 + 9abdf41 commit 6d7a900

44 files changed

Lines changed: 2924 additions & 1372 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/core/plugin/plugin_blueprint.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package plugin
2020
import (
2121
"encoding/json"
2222

23+
"github.com/apache/devlake/core/dal"
2324
"github.com/apache/devlake/core/errors"
2425
"github.com/apache/devlake/core/models"
2526
)
@@ -92,6 +93,23 @@ type ProjectMapper interface {
9293
MapProject(projectName string, scopes []Scope) (models.PipelinePlan, errors.Error)
9394
}
9495

96+
// ProjectDeleteHook is implemented by plugins that persist state whose
97+
// lifecycle is tied to a DevLake project.
98+
//
99+
// BeforeDeleteProject is called inside the core deletion transaction before
100+
// the project's core records are deleted. Returning an error aborts deletion.
101+
//
102+
// Contract:
103+
// - Database mutations must use only the supplied transaction.
104+
// - The plugin must not commit or roll back the transaction.
105+
// - External side effects cannot be rolled back by the database transaction.
106+
// - Plugins must not depend on hook execution order across plugins.
107+
// Plugins that do not maintain project-scoped state do not need to implement
108+
// this interface.
109+
type ProjectDeleteHook interface {
110+
BeforeDeleteProject(tx dal.Transaction, projectName string) errors.Error
111+
}
112+
95113
// CompositeDataSourcePluginBlueprintV200 is for unit test
96114
type CompositeDataSourcePluginBlueprintV200 interface {
97115
PluginMeta

‎backend/helpers/pluginhelper/services/blueprint_helper.go‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,18 @@ func (b *BlueprintManager) DeleteBlueprint(id uint64) errors.Error {
262262
}
263263
}
264264
}()
265-
err = tx.Delete(&models.BlueprintLabel{}, dal.Where("blueprint_id = ?", id))
265+
err = b.DeleteBlueprintInTransaction(tx, id)
266+
if err != nil {
267+
return err
268+
}
269+
err = tx.Commit()
270+
return err
271+
}
272+
273+
// DeleteBlueprintInTransaction removes a blueprint and its dependent records using
274+
// the caller's transaction. The caller owns commit and rollback.
275+
func (b *BlueprintManager) DeleteBlueprintInTransaction(tx dal.Transaction, id uint64) errors.Error {
276+
err := tx.Delete(&models.BlueprintLabel{}, dal.Where("blueprint_id = ?", id))
266277
if err != nil {
267278
return err
268279
}
@@ -274,9 +285,15 @@ func (b *BlueprintManager) DeleteBlueprint(id uint64) errors.Error {
274285
if err != nil {
275286
return err
276287
}
277-
errors.Must(tx.Delete(&models.BlueprintConnection{}, dal.Where("blueprint_id = ?", id)))
278-
errors.Must(tx.Delete(&models.BlueprintScope{}, dal.Where("blueprint_id = ?", id)))
279-
return tx.Commit()
288+
err = tx.Delete(&models.BlueprintConnection{}, dal.Where("blueprint_id = ?", id))
289+
if err != nil {
290+
return err
291+
}
292+
err = tx.Delete(&models.BlueprintScope{}, dal.Where("blueprint_id = ?", id))
293+
if err != nil {
294+
return err
295+
}
296+
return nil
280297
}
281298

282299
func (b *BlueprintManager) fillBlueprintDetail(blueprint *models.Blueprint) errors.Error {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package services
19+
20+
import (
21+
"testing"
22+
23+
"github.com/apache/devlake/core/errors"
24+
mockdal "github.com/apache/devlake/mocks/core/dal"
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/mock"
27+
)
28+
29+
func TestDeleteBlueprintInTransaction(t *testing.T) {
30+
t.Run("uses the caller transaction for every delete", func(t *testing.T) {
31+
tx := mockdal.NewTransaction(t)
32+
tx.On("Delete", mock.Anything, mock.Anything).Return(nil)
33+
tx.On("Delete", mock.Anything).Return(nil)
34+
35+
manager := &BlueprintManager{}
36+
err := manager.DeleteBlueprintInTransaction(tx, 42)
37+
38+
assert.NoError(t, err)
39+
assert.Len(t, tx.Calls, 4)
40+
})
41+
42+
t.Run("returns dependent deletion failures", func(t *testing.T) {
43+
expected := errors.Default.New("unable to delete blueprint")
44+
tx := mockdal.NewTransaction(t)
45+
tx.On("Delete", mock.AnythingOfType("*models.Blueprint")).Return(expected).Once()
46+
tx.On("Delete", mock.Anything, mock.Anything).Return(nil)
47+
48+
manager := &BlueprintManager{}
49+
err := manager.DeleteBlueprintInTransaction(tx, 42)
50+
51+
assert.ErrorIs(t, err, expected)
52+
assert.Len(t, tx.Calls, 2)
53+
})
54+
}

‎backend/plugins/github/tasks/pr_collector.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ func CollectApiPullRequests(taskCtx plugin.SubTaskContext) errors.Error {
129129
res.Body.Close()
130130
return []json.RawMessage{body}, nil
131131
},
132+
AfterResponse: ignoreHTTPStatus404,
132133
},
133134
},
134135
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package tasks
19+
20+
import (
21+
"bytes"
22+
"io"
23+
"net/http"
24+
"testing"
25+
26+
"github.com/apache/devlake/core/errors"
27+
"github.com/apache/devlake/helpers/pluginhelper/api"
28+
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/require"
30+
)
31+
32+
func makeTestResponse(statusCode int) *http.Response {
33+
return &http.Response{
34+
StatusCode: statusCode,
35+
Body: io.NopCloser(bytes.NewBufferString("")),
36+
Request: &http.Request{},
37+
}
38+
}
39+
40+
func TestIgnoreHTTPStatus404(t *testing.T) {
41+
tests := []struct {
42+
name string
43+
statusCode int
44+
wantIgnore bool
45+
wantUnauth bool
46+
wantNil bool
47+
}{
48+
{
49+
name: "404 returns ErrIgnoreAndContinue",
50+
statusCode: http.StatusNotFound,
51+
wantIgnore: true,
52+
},
53+
{
54+
name: "401 returns unauthorized error",
55+
statusCode: http.StatusUnauthorized,
56+
wantUnauth: true,
57+
},
58+
{
59+
name: "200 returns nil",
60+
statusCode: http.StatusOK,
61+
wantNil: true,
62+
},
63+
{
64+
name: "403 returns nil",
65+
statusCode: http.StatusForbidden,
66+
wantNil: true,
67+
},
68+
{
69+
name: "500 returns nil",
70+
statusCode: http.StatusInternalServerError,
71+
wantNil: true,
72+
},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
res := makeTestResponse(tt.statusCode)
78+
err := ignoreHTTPStatus404(res)
79+
if tt.wantIgnore {
80+
assert.Equal(t, api.ErrIgnoreAndContinue, err)
81+
} else if tt.wantUnauth {
82+
require.NotNil(t, err)
83+
assert.Equal(t, errors.Unauthorized, err.GetType())
84+
} else if tt.wantNil {
85+
assert.Nil(t, err)
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestIgnoreHTTPStatus422(t *testing.T) {
92+
tests := []struct {
93+
name string
94+
statusCode int
95+
wantIgnore bool
96+
wantNil bool
97+
}{
98+
{
99+
name: "422 returns ErrIgnoreAndContinue",
100+
statusCode: http.StatusUnprocessableEntity,
101+
wantIgnore: true,
102+
},
103+
{
104+
name: "200 returns nil",
105+
statusCode: http.StatusOK,
106+
wantNil: true,
107+
},
108+
{
109+
name: "404 returns nil",
110+
statusCode: http.StatusNotFound,
111+
wantNil: true,
112+
},
113+
}
114+
115+
for _, tt := range tests {
116+
t.Run(tt.name, func(t *testing.T) {
117+
res := makeTestResponse(tt.statusCode)
118+
err := ignoreHTTPStatus422(res)
119+
if tt.wantIgnore {
120+
assert.Equal(t, api.ErrIgnoreAndContinue, err)
121+
} else if tt.wantNil {
122+
assert.Nil(t, err)
123+
}
124+
})
125+
}
126+
}

‎backend/plugins/jira/api/connection_api.go‎

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ package api
1919

2020
import (
2121
"context"
22+
"encoding/json"
2223
"fmt"
24+
"io"
2325
"net/http"
2426
"net/url"
2527
"strings"
@@ -39,8 +41,13 @@ type JiraTestConnResponse struct {
3941
}
4042

4143
func testConnection(ctx context.Context, connection models.JiraConn) (*JiraTestConnResponse, errors.Error) {
42-
// validate
43-
if vld != nil {
44+
// OAuth2 is plugin-specific and is not in MultiAuth's oneof, so validate it
45+
// separately. Other auth methods keep the existing StructExcept path.
46+
if connection.IsOAuth2() {
47+
if err := connection.ValidateConnection(&connection, vld); err != nil {
48+
return nil, err
49+
}
50+
} else if vld != nil {
4451
e := vld.StructExcept(connection, "BasicAuth", "AccessToken")
4552
if e != nil {
4653
return nil, errors.Convert(e)
@@ -74,10 +81,12 @@ func testConnection(ctx context.Context, connection models.JiraConn) (*JiraTestC
7481
return nil, errors.NotFound.New(fmt.Sprintf("Seems like an invalid Endpoint URL, please try %s", restUrl.String()))
7582
}
7683
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
77-
return nil, errors.HttpStatus(res.StatusCode).New("Please check your credential")
84+
// 400 not 401: Config UI treats HTTP 401 as a DevLake session expiry and
85+
// navigates to /login, wiping the connection form.
86+
return nil, errors.BadInput.New(withJiraHTTPDetail(res, "Please check your credential"))
7887
}
7988
if res.StatusCode != http.StatusOK {
80-
return nil, errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("%s unexpected status code: %d", serverInfoFail, res.StatusCode))
89+
return nil, errors.HttpStatus(res.StatusCode).New(withJiraHTTPDetail(res, fmt.Sprintf("%s unexpected status code: %d", serverInfoFail, res.StatusCode)))
8190
}
8291

8392
resBody := &models.JiraServerInfo{}
@@ -101,13 +110,16 @@ func testConnection(ctx context.Context, connection models.JiraConn) (*JiraTestC
101110
}
102111
getStatusFail += ": [ " + res.Request.URL.String() + " ]"
103112

104-
errMsg := ""
105-
if res.StatusCode == http.StatusUnauthorized {
106-
return nil, errors.HttpStatus(res.StatusCode).New("Please check your credential")
113+
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
114+
return nil, errors.BadInput.New(withJiraHTTPDetail(res, fmt.Sprintf(
115+
"Jira accepted the connection (serverInfo OK) but GET %s returned %d",
116+
res.Request.URL.String(),
117+
res.StatusCode,
118+
)))
107119
}
108120

109121
if res.StatusCode != http.StatusOK {
110-
return nil, errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("%s Unexpected [%s] status code: %d %s", getStatusFail, res.Request.URL, res.StatusCode, errMsg))
122+
return nil, errors.HttpStatus(res.StatusCode).New(withJiraHTTPDetail(res, fmt.Sprintf("%s Unexpected [%s] status code: %d", getStatusFail, res.Request.URL, res.StatusCode)))
111123
}
112124
connection = connection.Sanitize()
113125
body := JiraTestConnResponse{}
@@ -222,3 +234,62 @@ func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput,
222234
func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
223235
return dsHelper.ConnApi.GetDetail(input)
224236
}
237+
238+
func withJiraHTTPDetail(res *http.Response, msg string) string {
239+
if detail := jiraHTTPErrorDetail(res); detail != "" {
240+
return msg + ": " + detail
241+
}
242+
return msg
243+
}
244+
245+
// jiraHTTPErrorDetail reads Jira's error payload. Classic REST uses
246+
// errorMessages; the Atlassian OAuth gateway often uses message and/or
247+
// WWW-Authenticate (e.g. insufficient_scope).
248+
func jiraHTTPErrorDetail(res *http.Response) string {
249+
if res == nil || res.Body == nil {
250+
return ""
251+
}
252+
defer res.Body.Close()
253+
raw, err := io.ReadAll(io.LimitReader(res.Body, 4096))
254+
if err != nil {
255+
return ""
256+
}
257+
if parsed := parseJiraErrorBody(strings.TrimSpace(string(raw))); parsed != "" {
258+
return parsed
259+
}
260+
if auth := strings.TrimSpace(res.Header.Get("WWW-Authenticate")); auth != "" {
261+
return auth
262+
}
263+
text := strings.TrimSpace(string(raw))
264+
if text == "" || strings.HasPrefix(text, "<") {
265+
return ""
266+
}
267+
const maxLen = 500
268+
if len(text) > maxLen {
269+
return text[:maxLen] + "..."
270+
}
271+
return text
272+
}
273+
274+
func parseJiraErrorBody(text string) string {
275+
if text == "" || text[0] != '{' {
276+
return ""
277+
}
278+
var payload struct {
279+
ErrorMessages []string `json:"errorMessages"`
280+
Message string `json:"message"`
281+
}
282+
if json.Unmarshal([]byte(text), &payload) != nil {
283+
return ""
284+
}
285+
parts := make([]string, 0, len(payload.ErrorMessages)+1)
286+
for _, m := range payload.ErrorMessages {
287+
if m = strings.TrimSpace(m); m != "" {
288+
parts = append(parts, m)
289+
}
290+
}
291+
if m := strings.TrimSpace(payload.Message); m != "" {
292+
parts = append(parts, m)
293+
}
294+
return strings.Join(parts, "; ")
295+
}

0 commit comments

Comments
 (0)