Skip to content

Commit 7e6cf14

Browse files
authored
campaigns: suggest alternatives when campaigns are unlicensed (#410)
1 parent 6bb3ddd commit 7e6cf14

5 files changed

Lines changed: 255 additions & 13 deletions

File tree

cmd/src/campaigns_common.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,6 @@ func campaignsExecute(ctx context.Context, out *output.Output, svc *campaigns.Se
242242
specs, err := svc.ExecuteCampaignSpec(ctx, repos, executor, campaignSpec, p.PrintStatuses, flags.skipErrors)
243243
if err != nil && !flags.skipErrors {
244244
return "", "", err
245-
246245
}
247246
p.Complete()
248247
if err != nil && flags.skipErrors {
@@ -292,10 +291,10 @@ func campaignsExecute(ctx context.Context, out *output.Output, svc *campaigns.Se
292291

293292
pending = campaignsCreatePending(out, "Creating campaign spec on Sourcegraph")
294293
id, url, err := svc.CreateCampaignSpec(ctx, namespace, rawSpec, ids)
294+
campaignsCompletePending(pending, "Creating campaign spec on Sourcegraph")
295295
if err != nil {
296-
return "", "", err
296+
return "", "", prettyPrintCampaignsUnlicensedError(out, err)
297297
}
298-
campaignsCompletePending(pending, "Creating campaign spec on Sourcegraph")
299298

300299
return id, url, nil
301300
}
@@ -331,6 +330,12 @@ func campaignsParseSpec(out *output.Output, svc *campaigns.Service, input io.Rea
331330
// printExecutionError is used to print the possible error returned by
332331
// campaignsExecute.
333332
func printExecutionError(out *output.Output, err error) {
333+
// exitCodeError shouldn't generate any specific output, since it indicates
334+
// that this was done deeper in the call stack.
335+
if _, ok := err.(*exitCodeError); ok {
336+
return
337+
}
338+
334339
out.Write("")
335340

336341
writeErrs := func(errs []error) {
@@ -356,7 +361,7 @@ func printExecutionError(out *output.Output, err error) {
356361
}
357362

358363
switch err := err.(type) {
359-
case parallel.Errors, *multierror.Error:
364+
case parallel.Errors, *multierror.Error, api.GraphQlErrors:
360365
writeErrs(flattenErrs(err))
361366

362367
default:
@@ -376,6 +381,12 @@ func flattenErrs(err error) (result []error) {
376381
for _, e := range errs.Errors {
377382
result = append(result, flattenErrs(e)...)
378383
}
384+
385+
case api.GraphQlErrors:
386+
for _, e := range errs {
387+
result = append(result, flattenErrs(e)...)
388+
}
389+
379390
default:
380391
result = append(result, errs)
381392
}
@@ -403,6 +414,48 @@ func formatTaskExecutionErr(err campaigns.TaskExecutionErr) string {
403414
)
404415
}
405416

417+
// prettyPrintCampaignsUnlicensedError introspects the given error returned when
418+
// creating a campaign spec and ascertains whether it's a licensing error. If it
419+
// is, then a better message is output. Regardless, the return value of this
420+
// function should be used to replace the original error passed in to ensure
421+
// that the displayed output is sensible.
422+
func prettyPrintCampaignsUnlicensedError(out *output.Output, err error) error {
423+
// Pull apart the error to see if it's a licensing error: if so, we should
424+
// display a friendlier and more actionable message than the usual GraphQL
425+
// error output.
426+
if gerrs, ok := err.(api.GraphQlErrors); ok {
427+
// A licensing error should be the sole error returned, so we'll only
428+
// pretty print if there's one error.
429+
if len(gerrs) == 1 {
430+
if code, cerr := gerrs[0].Code(); cerr != nil {
431+
// We got a malformed value in the error extensions; at this
432+
// point, there's not much sensible we can do. Let's log this in
433+
// verbose mode, but let the original error bubble up rather
434+
// than this one.
435+
out.Verbosef("Unexpected error parsing the GraphQL error: %v", cerr)
436+
} else if code == "ErrCampaignsUnlicensed" {
437+
// OK, let's print a better message, then return an
438+
// exitCodeError to suppress the normal automatic error block.
439+
// Note that we have hand wrapped the output at 80 (printable)
440+
// characters: having automatic wrapping some day would be nice,
441+
// but this should be sufficient for now.
442+
block := out.Block(output.Line("🪙", output.StyleWarning, "Campaigns is a paid feature of Sourcegraph. All users can create sample"))
443+
block.WriteLine(output.Linef("", output.StyleWarning, "campaigns with up to 5 changesets without a license. Contact Sourcegraph sales"))
444+
block.WriteLine(output.Linef("", output.StyleWarning, "at %shttps://about.sourcegraph.com/contact/sales/%s to obtain a trial license.", output.StyleSearchLink, output.StyleWarning))
445+
block.Write("")
446+
block.WriteLine(output.Linef("", output.StyleWarning, "To proceed with this campaign, you will need to create 5 or fewer changesets."))
447+
block.WriteLine(output.Linef("", output.StyleWarning, "To do so, you could try adding %scount:5%s to your %srepositoriesMatchingQuery%s search,", output.StyleSearchAlertProposedQuery, output.StyleWarning, output.StyleReset, output.StyleWarning))
448+
block.WriteLine(output.Linef("", output.StyleWarning, "or reduce the number of changesets in %simportChangesets%s.", output.StyleReset, output.StyleWarning))
449+
block.Close()
450+
return &exitCodeError{exitCode: graphqlErrorsExitCode}
451+
}
452+
}
453+
}
454+
455+
// In all other cases, we'll just return the original error.
456+
return err
457+
}
458+
406459
func sumDiffStats(fileDiffs []*diff.FileDiff) diff.Stat {
407460
sum := diff.Stat{}
408461
for _, fileDiff := range fileDiffs {

internal/api/api.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,9 @@ import (
1212
"os"
1313
"strings"
1414

15-
"github.com/hashicorp/go-multierror"
1615
ioaux "github.com/jig/teereadcloser"
1716
"github.com/kballard/go-shellquote"
1817
"github.com/mattn/go-isatty"
19-
"github.com/pkg/errors"
2018
"github.com/sourcegraph/codeintelutils"
2119
)
2220

@@ -257,6 +255,10 @@ func (r *request) do(ctx context.Context, result interface{}) (bool, error) {
257255
return true, nil
258256
}
259257

258+
// Do executes the request. Successful requests will be unmarshalled into the
259+
// given result. If GraphQL errors are returned, then the returned error will be
260+
// an instance of GraphQlErrors. Other errors (such as HTTP or network errors)
261+
// will be returned as-is.
260262
func (r *request) Do(ctx context.Context, result interface{}) (bool, error) {
261263
raw := rawResult{Data: result}
262264
ok, err := r.do(ctx, &raw)
@@ -268,11 +270,11 @@ func (r *request) Do(ctx context.Context, result interface{}) (bool, error) {
268270

269271
// Handle the case of unpacking errors.
270272
if raw.Errors != nil {
271-
var errs *multierror.Error
273+
errs := GraphQlErrors{}
272274
for _, err := range raw.Errors {
273-
errs = multierror.Append(errs, &graphqlError{err})
275+
errs = append(errs, &GraphQlError{err})
274276
}
275-
return false, errors.Wrap(errs, "GraphQL errors")
277+
return false, errs
276278
}
277279
return true, nil
278280
}

internal/api/api_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package api
2+
3+
// TODO: implement a super basic GraphQL server that can return canned results.

internal/api/errors.go

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,76 @@
11
package api
22

3-
import "encoding/json"
3+
import (
4+
"encoding/json"
45

5-
// graphqlError wraps a raw JSON error returned from a GraphQL endpoint.
6-
type graphqlError struct{ v interface{} }
6+
"github.com/hashicorp/go-multierror"
7+
"github.com/pkg/errors"
8+
)
79

8-
func (g *graphqlError) Error() string {
10+
// GraphQlErrors contains one or more GraphQlError instances.
11+
type GraphQlErrors []*GraphQlError
12+
13+
func (gg GraphQlErrors) Error() string {
14+
// This slightly convoluted implementation is used to ensure that output
15+
// remains stable with earlier versions of src-cli, which returned a wrapped
16+
// *multierror.Error when GraphQL errors were returned from the API.
17+
18+
if len(gg) == 0 {
19+
// This shouldn't really happen, but let's handle it gracefully anyway.
20+
return ""
21+
}
22+
23+
var errs *multierror.Error
24+
for _, err := range gg {
25+
errs = multierror.Append(errs, err)
26+
}
27+
28+
return errors.Wrap(errs.ErrorOrNil(), "GraphQL errors").Error()
29+
}
30+
31+
// GraphQlError wraps a raw JSON error returned from a GraphQL endpoint.
32+
type GraphQlError struct{ v interface{} }
33+
34+
// Code returns the GraphQL error code, if one was set on the error.
35+
func (g *GraphQlError) Code() (string, error) {
36+
ext, err := g.Extensions()
37+
if err != nil {
38+
return "", errors.Wrap(err, "getting error extensions")
39+
}
40+
41+
if ext != nil {
42+
if ext["code"] == nil {
43+
return "", nil
44+
} else if code, ok := ext["code"].(string); ok {
45+
return code, nil
46+
}
47+
return "", errors.Errorf("unexpected code of type %T", ext["code"])
48+
}
49+
return "", nil
50+
}
51+
52+
func (g *GraphQlError) Error() string {
953
j, _ := json.MarshalIndent(g.v, "", " ")
1054
return string(j)
1155
}
56+
57+
// Extensions returns the GraphQL error extensions, if set, or nil if no
58+
// extensions were set on the error.
59+
func (g *GraphQlError) Extensions() (map[string]interface{}, error) {
60+
e, ok := g.v.(map[string]interface{})
61+
if !ok {
62+
return nil, errors.Errorf("unexpected GraphQL error of type %T", g.v)
63+
}
64+
65+
if e["extensions"] == nil {
66+
return nil, nil
67+
} else if me, ok := e["extensions"].(map[string]interface{}); ok {
68+
return me, nil
69+
}
70+
return nil, errors.Errorf("unexpected extensions of type %T", e["extensions"])
71+
}
72+
73+
var (
74+
_ error = &GraphQlError{}
75+
_ error = GraphQlErrors{}
76+
)

internal/api/errors_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestGraphQLError_Code(t *testing.T) {
9+
for name, tc := range map[string]struct {
10+
in string
11+
want string
12+
wantErr bool
13+
}{
14+
"invalid code": {
15+
in: `{
16+
"errors": [
17+
{
18+
"message": "The feature \"campaigns\" is not activated because it requires a valid Sourcegraph license. Purchase a Sourcegraph subscription to activate this feature.",
19+
"path": [
20+
"createCampaignSpec"
21+
],
22+
"extensions": {
23+
"code": 42
24+
}
25+
}
26+
],
27+
"data": null
28+
}`,
29+
wantErr: true,
30+
},
31+
"invalid extensions": {
32+
in: `{
33+
"errors": [
34+
{
35+
"message": "The feature \"campaigns\" is not activated because it requires a valid Sourcegraph license. Purchase a Sourcegraph subscription to activate this feature.",
36+
"path": [
37+
"createCampaignSpec"
38+
],
39+
"extensions": 42
40+
}
41+
],
42+
"data": null
43+
}`,
44+
wantErr: true,
45+
},
46+
"no code": {
47+
in: `{
48+
"errors": [
49+
{
50+
"message": "The feature \"campaigns\" is not activated because it requires a valid Sourcegraph license. Purchase a Sourcegraph subscription to activate this feature.",
51+
"path": [
52+
"createCampaignSpec"
53+
],
54+
"extensions": {}
55+
}
56+
],
57+
"data": null
58+
}`,
59+
want: "",
60+
},
61+
"no extensions": {
62+
in: `{
63+
"errors": [
64+
{
65+
"message": "The feature \"campaigns\" is not activated because it requires a valid Sourcegraph license. Purchase a Sourcegraph subscription to activate this feature.",
66+
"path": [
67+
"createCampaignSpec"
68+
]
69+
}
70+
],
71+
"data": null
72+
}`,
73+
want: "",
74+
},
75+
"valid code": {
76+
in: `{
77+
"errors": [
78+
{
79+
"message": "The feature \"campaigns\" is not activated because it requires a valid Sourcegraph license. Purchase a Sourcegraph subscription to activate this feature.",
80+
"path": [
81+
"createCampaignSpec"
82+
],
83+
"extensions": {
84+
"code": "ErrCampaignsUnlicensed"
85+
}
86+
}
87+
],
88+
"data": null
89+
}`,
90+
want: "ErrCampaignsUnlicensed",
91+
},
92+
} {
93+
t.Run(name, func(t *testing.T) {
94+
var result rawResult
95+
if err := json.Unmarshal([]byte(tc.in), &result); err != nil {
96+
t.Fatal(err)
97+
}
98+
if ne := len(result.Errors); ne != 1 {
99+
t.Fatalf("unexpected number of GraphQL errors (this test can only handle one!): %d", ne)
100+
}
101+
102+
ge := &GraphQlError{result.Errors[0]}
103+
have, err := ge.Code()
104+
if tc.wantErr {
105+
if err == nil {
106+
t.Errorf("unexpected nil error")
107+
}
108+
} else {
109+
if err != nil {
110+
t.Errorf("unexpected error: %+v", err)
111+
}
112+
if have != tc.want {
113+
t.Errorf("unexpected code: have=%q want=%q", have, tc.want)
114+
}
115+
}
116+
})
117+
}
118+
119+
}

0 commit comments

Comments
 (0)