1
0
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:
Furkan Türkal 2021-11-07 03:04:08 +03:00 committed by GitHub
parent cf73ede932
commit aa41862fe0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 397 additions and 0 deletions

View File

@ -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{},

View 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)
}

View 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)
}
}

View 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
}

View 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))
})
}

View File

@ -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"`

View File

@ -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{},
}

View 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/).

View File

@ -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