mirror of
https://github.com/goreleaser/goreleaser.git
synced 2025-03-11 14:39:28 +02:00
feat: announce to LinkedIn (#2492)
Fixes #2428 Signed-off-by: Furkan <furkan.turkal@trendyol.com> Co-authored-by: Batuhan Apaydin <batuhan.apaydin@trendyol.com> Co-authored-by: Erkan Zileli <erkan.zileli@trendyol.com> Co-authored-by: Batuhan Apaydin <batuhan.apaydin@trendyol.com> Co-authored-by: Erkan Zileli <erkan.zileli@trendyol.com> Co-authored-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
This commit is contained in:
parent
cf73ede932
commit
aa41862fe0
@ -16,6 +16,7 @@ import (
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/teams"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/telegram"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/twitter"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/linkedin"
|
||||
"github.com/goreleaser/goreleaser/internal/tmpl"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
)
|
||||
@ -33,6 +34,7 @@ var announcers = []Announcer{
|
||||
discord.Pipe{},
|
||||
mattermost.Pipe{},
|
||||
reddit.Pipe{},
|
||||
linkedin.Pipe{},
|
||||
slack.Pipe{},
|
||||
smtp.Pipe{},
|
||||
teams.Pipe{},
|
||||
|
134
internal/pipe/linkedin/client.go
Normal file
134
internal/pipe/linkedin/client.go
Normal file
@ -0,0 +1,134 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type oAuthClientConfig struct {
|
||||
Context *context.Context
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
type client struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type postShareText struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type postShareRequest struct {
|
||||
Text postShareText `json:"text"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
func createLinkedInClient(cfg oAuthClientConfig) (client, error) {
|
||||
if cfg.Context == nil {
|
||||
return client{}, fmt.Errorf("context is nil")
|
||||
}
|
||||
|
||||
if cfg.AccessToken == "" {
|
||||
return client{}, fmt.Errorf("empty access token")
|
||||
}
|
||||
|
||||
config := oauth2.Config{}
|
||||
|
||||
c := config.Client(cfg.Context, &oauth2.Token{
|
||||
AccessToken: cfg.AccessToken,
|
||||
})
|
||||
|
||||
if c == nil {
|
||||
return client{}, fmt.Errorf("client is nil")
|
||||
}
|
||||
|
||||
return client{
|
||||
client: c,
|
||||
baseURL: "https://api.linkedin.com",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getProfileID returns the Current Member's ID
|
||||
// POST Share API requires a Profile ID in the 'owner' field
|
||||
// Format must be in: 'urn:li:person:PROFILE_ID'
|
||||
// https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api#retrieve-current-members-profile
|
||||
func (c client) getProfileID() (string, error) {
|
||||
resp, err := c.client.Get(c.baseURL + "/v2/me")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not GET /v2/me: %w", err)
|
||||
}
|
||||
|
||||
value, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read response body: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(value, &result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not unmarshal: %w", err)
|
||||
}
|
||||
|
||||
if v, ok := result["id"]; ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find 'id' in result: %w", err)
|
||||
}
|
||||
|
||||
func (c client) Share(message string) (string, error) {
|
||||
// To get Owner of the share, we need to get profile id
|
||||
profileID, err := c.getProfileID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get profile id: %w", err)
|
||||
}
|
||||
|
||||
req := postShareRequest{
|
||||
Text: postShareText{
|
||||
Text: message,
|
||||
},
|
||||
// Person or Organization URN
|
||||
// Owner of the share. Required on create.
|
||||
// https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#schema
|
||||
Owner: fmt.Sprintf("urn:li:person:%s", profileID),
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Filling only required 'owner' and 'text' field is OK
|
||||
// https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#sample-request-3
|
||||
resp, err := c.client.Post(c.baseURL+"/v2/shares", "application/json", bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not POST /v2/shares: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read from body: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not unmarshal: %w", err)
|
||||
}
|
||||
|
||||
// Activity URN
|
||||
// URN of the activity associated with this share. Activities act as a wrapper around
|
||||
// shares and articles to represent content in the LinkedIn feed. Read only.
|
||||
if v, ok := result["activity"]; ok {
|
||||
return fmt.Sprintf("https://www.linkedin.com/feed/update/%s", v.(string)), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find 'activity' in result: %w", err)
|
||||
}
|
88
internal/pipe/linkedin/client_test.go
Normal file
88
internal/pipe/linkedin/client_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/goreleaser/goreleaser/pkg/config"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
)
|
||||
|
||||
func TestCreateLinkedInClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg OAuthClientConfig
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
"non-empty context and access token",
|
||||
OAuthClientConfig{
|
||||
Context: context.New(config.Project{}),
|
||||
AccessToken: "foo",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"empty context",
|
||||
OAuthClientConfig{
|
||||
Context: nil,
|
||||
AccessToken: "foo",
|
||||
},
|
||||
fmt.Errorf("context is nil"),
|
||||
},
|
||||
{
|
||||
"empty access token",
|
||||
OAuthClientConfig{
|
||||
Context: context.New(config.Project{}),
|
||||
AccessToken: "",
|
||||
},
|
||||
fmt.Errorf("empty access token"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := CreateLinkedInClient(tt.cfg)
|
||||
|
||||
if !reflect.DeepEqual(err, tt.wantErr) {
|
||||
t.Errorf("CreateLinkedInClient() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Share(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
_, _ = rw.Write([]byte(`
|
||||
{
|
||||
"id": "foo",
|
||||
"activity": "123456789"
|
||||
}
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c, err := CreateLinkedInClient(OAuthClientConfig{
|
||||
Context: context.New(config.Project{}),
|
||||
AccessToken: "foo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not create client: %v", err)
|
||||
}
|
||||
|
||||
c.baseURL = server.URL
|
||||
|
||||
link, err := c.Share("test")
|
||||
if err != nil {
|
||||
t.Fatalf("could not share: %v", err)
|
||||
}
|
||||
|
||||
wantLink := "https://www.linkedin.com/feed/update/123456789"
|
||||
|
||||
if link != wantLink {
|
||||
t.Fatalf("link got: %s want: %s", link, wantLink)
|
||||
}
|
||||
}
|
60
internal/pipe/linkedin/linkedin.go
Normal file
60
internal/pipe/linkedin/linkedin.go
Normal file
@ -0,0 +1,60 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/apex/log"
|
||||
"github.com/caarlos0/env/v6"
|
||||
"github.com/goreleaser/goreleaser/internal/tmpl"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMessageTemplate = `{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .GitURL }}/releases/tag/{{ .Tag }}`
|
||||
)
|
||||
|
||||
type Pipe struct{}
|
||||
|
||||
func (Pipe) String() string { return "linkedin" }
|
||||
func (Pipe) Skip(ctx *context.Context) bool { return !ctx.Config.Announce.LinkedIn.Enabled }
|
||||
|
||||
type Config struct {
|
||||
AccessToken string `env:"LINKEDIN_ACCESS_TOKEN,notEmpty"`
|
||||
}
|
||||
|
||||
func (Pipe) Default(ctx *context.Context) error {
|
||||
if ctx.Config.Announce.LinkedIn.MessageTemplate == "" {
|
||||
ctx.Config.Announce.LinkedIn.MessageTemplate = defaultMessageTemplate
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Pipe) Announce(ctx *context.Context) error {
|
||||
message, err := tmpl.New(ctx).Apply(ctx.Config.Announce.LinkedIn.MessageTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to linkedin: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := env.Parse(&cfg); err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to linkedin: %w", err)
|
||||
}
|
||||
|
||||
c, err := createLinkedInClient(oAuthClientConfig{
|
||||
Context: ctx,
|
||||
AccessToken: cfg.AccessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to linkedin: %w", err)
|
||||
}
|
||||
|
||||
url, err := c.Share(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to linkedin: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("announce: The text post is available at: %s\n", url)
|
||||
|
||||
return nil
|
||||
}
|
79
internal/pipe/linkedin/linkedin_test.go
Normal file
79
internal/pipe/linkedin/linkedin_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
package linkedin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goreleaser/goreleaser/internal/testlib"
|
||||
"github.com/goreleaser/goreleaser/pkg/config"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStringer(t *testing.T) {
|
||||
require.Equal(t, Pipe{}.String(), "linkedin")
|
||||
}
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
ctx := context.New(config.Project{})
|
||||
require.NoError(t, Pipe{}.Default(ctx))
|
||||
require.Equal(t, ctx.Config.Announce.LinkedIn.MessageTemplate, defaultMessageTemplate)
|
||||
}
|
||||
|
||||
func TestAnnounceDisabled(t *testing.T) {
|
||||
ctx := context.New(config.Project{})
|
||||
require.NoError(t, Pipe{}.Default(ctx))
|
||||
testlib.AssertSkipped(t, Pipe{}.Announce(ctx))
|
||||
}
|
||||
|
||||
func TestAnnounceInvalidTemplate(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
LinkedIn: config.LinkedIn{
|
||||
Enabled: true,
|
||||
MessageTemplate: "{{ .Foo }",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.EqualError(t, Pipe{}.Announce(ctx), `announce: failed to announce to linkedin: template: tmpl:1: unexpected "}" in operand`)
|
||||
}
|
||||
|
||||
func TestAnnounceMissingEnv(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
LinkedIn: config.LinkedIn{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, Pipe{}.Default(ctx))
|
||||
require.EqualError(t, Pipe{}.Announce(ctx), `announce: failed to announce to linkedin: env: environment variable "LINKEDIN_ACCESS_TOKEN" should not be empty`)
|
||||
}
|
||||
|
||||
func TestAnnounceSkipAnnounce(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
LinkedIn: config.LinkedIn{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.SkipAnnounce = true
|
||||
testlib.AssertSkipped(t, Pipe{}.Announce(ctx))
|
||||
}
|
||||
|
||||
func TestSkip(t *testing.T) {
|
||||
t.Run("skip", func(t *testing.T) {
|
||||
require.True(t, Pipe{}.Skip(context.New(config.Project{})))
|
||||
})
|
||||
|
||||
t.Run("dont skip", func(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
Reddit: config.Reddit{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.False(t, Pipe{}.Skip(ctx))
|
||||
})
|
||||
}
|
@ -823,6 +823,7 @@ type Announce struct {
|
||||
Teams Teams `yaml:"teams,omitempty"`
|
||||
SMTP SMTP `yaml:"smtp,omitempty"`
|
||||
Mattermost Mattermost `yaml:"mattermost,omitempty"`
|
||||
LinkedIn LinkedIn `yaml:"linkedin,omitempty"`
|
||||
Telegram Telegram `yaml:"telegram,omitempty"`
|
||||
}
|
||||
|
||||
@ -888,6 +889,11 @@ type SMTP struct {
|
||||
InsecureSkipVerify bool `yaml:"insecure_skip_verify,omitempty"`
|
||||
}
|
||||
|
||||
type LinkedIn struct {
|
||||
Enabled bool `yaml:"enabled,omitempty"`
|
||||
MessageTemplate string `yaml:"message_template,omitempty"`
|
||||
}
|
||||
|
||||
type Telegram struct {
|
||||
Enabled bool `yaml:"enabled,omitempty"`
|
||||
MessageTemplate string `yaml:"message_template,omitempty"`
|
||||
|
@ -31,6 +31,7 @@ import (
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/teams"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/telegram"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/twitter"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/linkedin"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/universalbinary"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
)
|
||||
@ -75,5 +76,6 @@ var Defaulters = []Defaulter{
|
||||
smtp.Pipe{},
|
||||
mattermost.Pipe{},
|
||||
milestone.Pipe{},
|
||||
linkedin.Pipe{},
|
||||
telegram.Pipe{},
|
||||
}
|
||||
|
25
www/docs/customization/announce/linkedin.md
Normal file
25
www/docs/customization/announce/linkedin.md
Normal file
@ -0,0 +1,25 @@
|
||||
# LinkedIn
|
||||
|
||||
For it to work, you'll need to set some environment variables on your pipeline:
|
||||
|
||||
- `LINKEDIN_ACCESS_TOKEN`
|
||||
|
||||
**P.S:** _We currently don't support posting in groups._
|
||||
|
||||
Then, you can add something like the following to your `.goreleaser.yml` config:
|
||||
|
||||
```yaml
|
||||
# .goreleaser.yml
|
||||
announce:
|
||||
linkedin:
|
||||
# Whether its enabled or not.
|
||||
# Defaults to false.
|
||||
enabled: true
|
||||
|
||||
# Message template to use while publishing.
|
||||
# Defaults to `{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .GitURL }}/releases/tag/{{ .Tag }}`
|
||||
message_template: 'Awesome project {{.Tag}} is out!'
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Learn more about the [name template engine](/customization/templates/).
|
@ -117,6 +117,7 @@ nav:
|
||||
- customization/announce/teams.md
|
||||
- customization/announce/twitter.md
|
||||
- customization/announce/mattermost.md
|
||||
- customization/announce/linkedin.md
|
||||
- customization/announce/telegram.md
|
||||
- Command Line Usage:
|
||||
- goreleaser: cmd/goreleaser.md
|
||||
|
Loading…
x
Reference in New Issue
Block a user