{"action":"created","comment":{"url":"https://api.github.com/repos/allegro/akubra/pulls/comments/230335820","pull_request_review_id":171052555,"id":230335820,"node_id":"MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDIzMDMzNTgyMA==","diff_hunk":"@@ -0,0 +1,615 @@\n+package balancing\n+\n+import (\n+\t\"fmt\"\n+\t\"math\"\n+\t\"net/http\"\n+\t\"sort\"\n+\t\"sync\"\n+\t\"time\"\n+\n+\t\"github.com/allegro/akubra/log\"\n+\t\"github.com/allegro/akubra/metrics\"\n+\t\"github.com/allegro/akubra/storages/backend\"\n+\t\"github.com/allegro/akubra/storages/config\"\n+)\n+\n+// ResponseTimeBalancer proxies calls to balancing nodes\n+type ResponseTimeBalancer struct {\n+\tNodes []Node\n+}\n+\n+// Elect elects node and calls it with args\n+func (balancer *ResponseTimeBalancer) Elect(skipNodes ...Node) (Node, error) {\n+\tstart := time.Now()\n+\tvar elected Node\n+\n+\tfor _, node := range balancer.Nodes {\n+\t\tif !node.IsActive() || inSkipNodes(skipNodes, node) {\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tif elected == nil {\n+\t\t\telected = node\n+\t\t\tcontinue\n+\t\t}\n+\n+\t\tif nodeWeight(node) < nodeWeight(elected) {\n+\t\t\telected = node\n+\t\t}\n+\t}\n+\tif elected == nil {\n+\t\treturn nil, ErrNoActiveNodes\n+\t}\n+\t// Disrupt node stats. If all nodes has zero weight only first would\n+\t// get all the load unless response will come\n+\telected.UpdateTimeSpent(time.Since(start))\n+\treturn elected, nil\n+}\n+\n+func inSkipNodes(skipNodes []Node, node Node) bool {\n+\tfor _, skipNode := range skipNodes {\n+\t\tif node == skipNode {\n+\t\t\treturn true\n+\t\t}\n+\t}\n+\treturn false\n+}\n+\n+// Node is interface of call node\n+type Node interface {\n+\tCalls() float64\n+\tTimeSpent() float64\n+\tIsActive() bool\n+\tSetActive(bool)\n+\tUpdateTimeSpent(time.Duration)\n+}\n+\n+func nodeWeight(node Node) float64 {\n+\treturn node.TimeSpent()\n+}\n+\n+var (\n+\t// ErrNoActiveNodes is issued if all nodes are inactive\n+\tErrNoActiveNodes = fmt.Errorf(\"Balancer has no nodes to call\")\n+)\n+\n+func newCallMeter(retention, resolution time.Duration) *CallMeter {\n+\treturn &CallMeter{\n+\t\tretention:  retention,\n+\t\tresolution: resolution,\n+\t\thistogram:  newTimeHistogram(retention, resolution, time.Now),\n+\t\tnow:        time.Now,\n+\t}\n+}\n+\n+func newCallMeterWithTimer(retention, resolution time.Duration, now func() time.Time) *CallMeter {\n+\tcm := newCallMeter(retention, resolution)\n+\tcm.now = now\n+\tcm.histogram = newTimeHistogram(retention, resolution, now)\n+\tcm.histogram.now = now\n+\treturn cm\n+}\n+\n+// CallMeter implements Node interface\n+type CallMeter struct {\n+\tretention     time.Duration\n+\tresolution    time.Duration\n+\tnow           func() time.Time\n+\thistogram     *histogram\n+\tinActiveSince time.Time\n+\tstatsShiftMx  sync.Mutex\n+}\n+\n+// UpdateTimeSpent aggregates data about call duration\n+func (meter *CallMeter) UpdateTimeSpent(duration time.Duration) {\n+\tseries := meter.histogram.pickSeries(meter.now())\n+\tif series == nil {\n+\t\treturn\n+\t}\n+\tseries.Add(float64(duration), meter.now())\n+}\n+\n+// Calls returns number of calls in last bucket\n+func (meter *CallMeter) Calls() float64 {\n+\treturn meter.CallsInLastPeriod(meter.resolution)\n+}\n+\n+// CallsInLastPeriod returns number of calls in last duration\n+func (meter *CallMeter) CallsInLastPeriod(period time.Duration) float64 {\n+\tlastPeriodSeries := meter.histogram.pickLastSeries(period)\n+\tsum := float64(0)\n+\tnow := meter.now()\n+\tfor _, series := range lastPeriodSeries {\n+\n+\t\tvalues := series.ValueRange(now.Add(-period), now)\n+\t\tsum += float64(len(values))\n+\t}\n+\treturn sum\n+}\n+\n+// IsActive aseses if node should be active\n+func (meter *CallMeter) IsActive() bool {\n+\treturn meter.inActiveSince == time.Time{}\n+}\n+\n+// SetActive sets meter state\n+func (meter *CallMeter) SetActive(active bool) {\n+\tif meter.IsActive() && !active {\n+\t\tmeter.inActiveSince = meter.now()\n+\t}\n+\tif !meter.IsActive() && active {\n+\t\tmeter.histogram.shiftData(meter.now().Sub(meter.inActiveSince))\n+\t\tmeter.inActiveSince = time.Time{}\n+\t}\n+}\n+\n+// TimeSpent returns float64 repesentation of time spent in execution\n+func (meter *CallMeter) TimeSpent() float64 {\n+\tallSeries := meter.histogram.pickLastSeries(meter.resolution)\n+\tsum := float64(0)\n+\tnow := meter.now()\n+\n+\tfor _, series := range allSeries {\n+\t\tseries.ValueRangeFun(now.Add(-meter.resolution), now, func(value *timeValue) {\n+\t\t\tsum += value.value\n+\t\t})\n+\t}\n+\n+\treturn sum\n+}\n+\n+type dataSeries struct {\n+\tdata []*timeValue\n+\tmx   sync.Mutex\n+}\n+\n+func (series *dataSeries) Add(value float64, dateTime time.Time) {\n+\tseries.mx.Lock()\n+\tdefer series.mx.Unlock()\n+\tseries.data = append(series.data, &timeValue{dateTime, value})\n+}\n+\n+func (series *dataSeries) ValueRangeFun(timeStart, timeEnd time.Time, fun func(*timeValue)) []float64 {\n+\tdataRange := []float64{}\n+\tfor _, timeVal := range series.data {\n+\t\tif (timeStart == timeVal.date || timeStart.Before(timeVal.date)) && timeEnd.After(timeVal.date) {\n+\t\t\tfun(timeVal)\n+\t\t}\n+\t}\n+\treturn dataRange\n+}\n+\n+func (series *dataSeries) ValueRange(timeStart, timeEnd time.Time) []float64 {\n+\tdataRange := []float64{}\n+\tseries.ValueRangeFun(timeStart, timeEnd, func(value *timeValue) {\n+\t\tdataRange = append(dataRange, value.value)\n+\t})\n+\treturn dataRange\n+}\n+\n+type timeValue struct {\n+\tdate  time.Time\n+\tvalue float64\n+}\n+\n+func newTimeHistogram(retention, resolution time.Duration, now func() time.Time) *histogram {\n+\treturn &histogram{\n+\t\tt0:         now(),\n+\t\tresolution: resolution,\n+\t\tretention:  retention,\n+\t\tnow:        now,\n+\t\tmx:         sync.Mutex{},\n+\t}\n+}\n+\n+type histogram struct {\n+\tt0         time.Time\n+\tretention  time.Duration\n+\tresolution time.Duration\n+\tdata       []*dataSeries\n+\tnow        func() time.Time\n+\tmx         sync.Mutex\n+}\n+\n+func (h *histogram) pickSeries(at time.Time) *dataSeries {\n+\th.mx.Lock()\n+\tdefer h.mx.Unlock()\n+\tidx := h.index(at)\n+\tif idx < 0 {\n+\t\treturn nil\n+\t}\n+\tif idx >= h.cellsCount() || idx >= len(h.data) {\n+\t\th.unshiftData(at)\n+\t\tidx = h.index(at)\n+\t}\n+\treturn h.data[idx]\n+}\n+\n+func (h *histogram) pickLastSeries(period time.Duration) []*dataSeries {\n+\th.mx.Lock()\n+\tdefer h.mx.Unlock()\n+\tif period > h.retention {\n+\t\tperiod = h.retention\n+\t}\n+\th.unshiftData(h.now())\n+\tcellsNumber := math.Ceil(float64(period)/float64(h.resolution)) + 1\n+\tnow := h.now()\n+\tstop := h.index(now) + 1\n+\tstart := int(math.Max(float64(stop-int(cellsNumber)), 0))\n+\treturn h.data[start:stop]\n+}\n+\n+func (h *histogram) index(now time.Time) int {\n+\tsinceStart := float64(now.Sub(h.t0))\n+\tidx := math.Floor(sinceStart / float64(h.resolution))\n+\treturn int(idx)\n+}\n+\n+func (h *histogram) cellsCount() int {\n+\treturn int(math.Ceil(float64(h.retention)/float64(h.resolution))) + 1\n+}\n+\n+func (h *histogram) growSeries() {\n+\tfor len(h.data) < h.cellsCount() {\n+\t\th.data = append(h.data, &dataSeries{mx: sync.Mutex{}})\n+\t}\n+}\n+\n+func (h *histogram) unshiftData(now time.Time) {\n+\tidx := h.index(now)\n+\tshiftSize := idx - len(h.data) + 1\n+\n+\tif shiftSize > 0 && shiftSize < len(h.data) {\n+\t\th.t0 = h.t0.Add(time.Duration(shiftSize) * h.resolution)\n+\t\th.data = h.data[shiftSize:]\n+\t}\n+\tif shiftSize > 0 && len(h.data) > 0 && shiftSize >= len(h.data) {\n+\t\th.t0 = now\n+\t\th.data = []*dataSeries{}\n+\t}\n+\th.growSeries()\n+}\n+func (h *histogram) shiftData(delta time.Duration) {\n+\tnewT0 := h.t0.Add(delta)\n+\tif newT0.After(h.now()) {\n+\t\treturn\n+\t}\n+\th.t0 = newT0\n+\tfor _, series := range h.data {\n+\t\tfor _, value := range series.data {\n+\t\t\tvalue.date = value.date.Add(delta)\n+\t\t}\n+\t}\n+}\n+\n+// Breaker is interface of citcuit breaker\n+type Breaker interface {\n+\tRecord(duration time.Duration, success bool) bool\n+\tShouldOpen() bool\n+}\n+\n+func newBreaker(retention int, callTimeLimit time.Duration,\n+\ttimeLimitPercentile, errorRate float64,\n+\tcloseDelay, maxDelay time.Duration) Breaker {\n+\treturn &NodeBreaker{\n+\t\ttimeData:            newLenLimitCounter(retention),\n+\t\tsuccessData:         newLenLimitCounter(retention),\n+\t\trate:                errorRate,\n+\t\tcallTimeLimit:       callTimeLimit,\n+\t\ttimeLimitPercentile: timeLimitPercentile,\n+\t\tnow:                 time.Now,\n+\t\tcloseDelay:          closeDelay,\n+\t\tmaxDelay:            maxDelay,\n+\t}\n+}\n+\n+// NodeBreaker is implementation of Breaker interface\n+type NodeBreaker struct {\n+\trate                float64\n+\tcallTimeLimit       time.Duration\n+\ttimeLimitPercentile float64\n+\ttimeData            *lengthDelimitedCounter\n+\tsuccessData         *lengthDelimitedCounter\n+\tnow                 func() time.Time\n+\tcloseDelay          time.Duration\n+\tmaxDelay            time.Duration\n+\tstate               *openStateTracker\n+}\n+\n+// Record collects call data and returns bool if breaker should be open\n+func (breaker *NodeBreaker) Record(duration time.Duration, success bool) bool {\n+\tbreaker.timeData.Add(float64(duration))\n+\tsuccessValue := float64(1)\n+\tif success {\n+\t\tsuccessValue = float64(0)\n+\t}\n+\tbreaker.successData.Add(successValue)\n+\treturn breaker.ShouldOpen()\n+}\n+\n+// ShouldOpen checks if breaker should be open\n+func (breaker *NodeBreaker) ShouldOpen() bool {\n+\texceeded := breaker.limitsExceeded()\n+\tif breaker.state != nil {\n+\t\treturn breaker.isHalfOpen(exceeded)\n+\t}\n+\n+\tif exceeded {\n+\t\tbreaker.openBreaker()\n+\t}\n+\treturn exceeded\n+}\n+\n+func (breaker *NodeBreaker) isHalfOpen(exceeded bool) bool {\n+\tstate, changed := breaker.state.currentState(breaker.now(), exceeded)\n+\tif state == closed {\n+\t\tif changed {\n+\t\t\tbreaker.state = nil\n+\t\t}\n+\t\treturn false\n+\t}\n+\tif state == halfopen {\n+\t\tif changed {\n+\t\t\tbreaker.reset()\n+\t\t}\n+\t\treturn false\n+\t}\n+\treturn true\n+}\n+\n+func (breaker *NodeBreaker) limitsExceeded() bool {\n+\terrorRate := breaker.errorRate()\n+\tif errorRate > breaker.rate {\n+\t\tbreaker.openBreaker()\n+\t\tlog.Debugf(\"Breaker: error rate exceeded %f\", errorRate)\n+\t\treturn true\n+\t}\n+\tpercentile := breaker.timeData.Percentile(breaker.timeLimitPercentile)\n+\tif percentile > float64(breaker.callTimeLimit) {\n+\t\tbreaker.openBreaker()\n+\t\tlog.Debugf(\"Breaker: time percentile exceeded %f\", percentile)\n+\t\treturn true\n+\t}\n+\treturn false\n+}\n+\n+func (breaker *NodeBreaker) openBreaker() {\n+\tif breaker.state != nil {\n+\t\treturn\n+\t}\n+\tbreaker.state = newOpenStateTracker(\n+\t\tbreaker.now(), breaker.closeDelay, breaker.maxDelay)\n+}\n+\n+func (breaker *NodeBreaker) reset() {\n+\tbreaker.timeData.Reset()\n+\tbreaker.successData.Reset()\n+}\n+\n+func (breaker *NodeBreaker) errorRate() float64 {\n+\tsum := breaker.successData.Sum()\n+\tcount := float64(len(breaker.successData.values))\n+\treturn sum / count\n+}\n+\n+func newLenLimitCounter(retention int) *lengthDelimitedCounter {\n+\treturn &lengthDelimitedCounter{\n+\t\tvalues: make([]float64, retention, retention),\n+\t}\n+}\n+\n+type lengthDelimitedCounter struct {\n+\tvalues  []float64\n+\tnextIdx int\n+\tmx      sync.Mutex\n+}\n+\n+// Add acumates new values\n+func (counter *lengthDelimitedCounter) Add(value float64) {\n+\tcounter.mx.Lock()\n+\tdefer counter.mx.Unlock()\n+\tindex := counter.nextIdx\n+\tcounter.values[index] = value\n+\tcounter.nextIdx = (counter.nextIdx + 1) % cap(counter.values)\n+}\n+\n+// Sum returns sum of values\n+func (counter *lengthDelimitedCounter) Sum() float64 {\n+\tsum := float64(0)\n+\tfor _, v := range counter.values {\n+\t\tsum += v\n+\t}\n+\treturn sum\n+}\n+\n+// Percentile return value for given percentile\n+func (counter *lengthDelimitedCounter) Percentile(percentile float64) float64 {\n+\tsnapshot := make([]float64, len(counter.values))\n+\tcopy(snapshot, counter.values)\n+\tsort.Float64s(snapshot)\n+\tpertcentileIndex := int(math.Floor(float64(len(snapshot)) * percentile))\n+\treturn snapshot[pertcentileIndex]\n+}\n+\n+func (counter *lengthDelimitedCounter) Reset() {\n+\tfor idx := range counter.values {\n+\t\tcounter.values[idx] = 0\n+\t}\n+}\n+\n+type breakerState int\n+\n+const (\n+\topen     breakerState = 0\n+\thalfopen              = iota\n+\tclosed                = iota\n+)\n+\n+func newOpenStateTracker(start time.Time, changeDelay, maxDelay time.Duration) *openStateTracker {\n+\treturn &openStateTracker{\n+\t\tlastChange:  start,\n+\t\tstate:       open,\n+\t\tchangeDelay: changeDelay,\n+\t\tmaxDelay:    maxDelay,\n+\t}\n+}\n+\n+type openStateTracker struct {\n+\tstate          breakerState\n+\tlastChange     time.Time\n+\tchangeDelay    time.Duration\n+\tmaxDelay       time.Duration\n+\tcloseIteration float64\n+}\n+\n+func (tracker *openStateTracker) currentDelay() time.Duration {\n+\tmultiplier := int(math.Pow(2, tracker.closeIteration))\n+\tdelayDuration := tracker.changeDelay * time.Duration(multiplier)\n+\n+\tif delayDuration < tracker.maxDelay {\n+\t\treturn delayDuration\n+\t}\n+\n+\treturn tracker.maxDelay\n+}\n+\n+func (tracker *openStateTracker) currentState(now time.Time, limitsExceeded bool) (breakerState, bool) {\n+\n+\tif limitsExceeded && tracker.state != open {\n+\t\ttracker.state = open\n+\t\ttracker.lastChange = now\n+\t\ttracker.closeIteration++\n+\t\treturn tracker.state, true\n+\t}\n+\n+\tchanged := false\n+\tif now.Sub(tracker.lastChange) < tracker.currentDelay() {\n+\t\treturn tracker.state, changed\n+\t}\n+\n+\tchanged = true\n+\ttracker.lastChange = now\n+\tif tracker.state == open {\n+\t\ttracker.state = halfopen\n+\t\treturn halfopen, changed\n+\t}\n+\n+\tif tracker.state == halfopen {\n+\t\tif limitsExceeded {\n+\t\t\ttracker.state = open\n+\t\t\ttracker.closeIteration++\n+\t\t} else {\n+\t\t\ttracker.state = closed\n+\t\t}\n+\t}\n+\treturn tracker.state, changed\n+}\n+\n+// MeasuredStorage coordinates metrics collection\n+type MeasuredStorage struct {\n+\tNode\n+\tBreaker\n+\thttp.RoundTripper\n+\tName           string\n+\twatcherStarted bool\n+}\n+\n+// RoundTrip implements http.RoundTripper\n+func (ms *MeasuredStorage) RoundTrip(req *http.Request) (*http.Response, error) {\n+\tstart := time.Now()\n+\treqID, _ := req.Context().Value(log.ContextreqIDKey).(string)\n+\tlog.Debugf(\"MeasuredStorage %s: Got request id %s\\n\", ms.Name, reqID)\n+\tresp, err := ms.RoundTripper.RoundTrip(req)\n+\tduration := time.Since(start)\n+\tsuccess := backend.IsSuccessful(resp, err)\n+\topen := ms.Breaker.Record(duration, success)\n+\tlog.Debugf(\"MeasuredStorage %s: Request %s took %s was successful: %t, opened breaker %t\\n\", ms.Name, reqID, duration, success, open)\n+\n+\tms.Node.UpdateTimeSpent(duration)\n+\tms.Node.SetActive(!open)\n+\traportMetrics(ms.RoundTripper, start, open)\n+\treturn resp, err\n+}\n+\n+// IsActive checks Breaker status propagates it to Node compound\n+func (ms *MeasuredStorage) IsActive() bool {","path":"balancing/balance_breaker.go","position":536,"original_position":536,"commit_id":"7e5e6ec66bf3263d2d9589b400c38cb9e51606d5","original_commit_id":"7e5e6ec66bf3263d2d9589b400c38cb9e51606d5","user":{"login":"wookie41","id":17747679,"node_id":"MDQ6VXNlcjE3NzQ3Njc5","avatar_url":"https://avatars3.githubusercontent.com/u/17747679?v=4","gravatar_id":"","url":"https://api.github.com/users/wookie41","html_url":"https://github.com/wookie41","followers_url":"https://api.github.com/users/wookie41/followers","following_url":"https://api.github.com/users/wookie41/following{/other_user}","gists_url":"https://api.github.com/users/wookie41/gists{/gist_id}","starred_url":"https://api.github.com/users/wookie41/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/wookie41/subscriptions","organizations_url":"https://api.github.com/users/wookie41/orgs","repos_url":"https://api.github.com/users/wookie41/repos","events_url":"https://api.github.com/users/wookie41/events{/privacy}","received_events_url":"https://api.github.com/users/wookie41/received_events","type":"User","site_admin":false},"body":"i don't like the fact that the method's name hides what it's actually doing and you have to take a look at the doc to know the whole truth. ","created_at":"2018-11-02T10:55:05Z","updated_at":"2018-11-02T10:55:05Z","html_url":"https://github.com/allegro/akubra/pull/107#discussion_r230335820","pull_request_url":"https://api.github.com/repos/allegro/akubra/pulls/107","author_association":"COLLABORATOR","_links":{"self":{"href":"https://api.github.com/repos/allegro/akubra/pulls/comments/230335820"},"html":{"href":"https://github.com/allegro/akubra/pull/107#discussion_r230335820"},"pull_request":{"href":"https://api.github.com/repos/allegro/akubra/pulls/107"}}},"pull_request":{"url":"https://api.github.com/repos/allegro/akubra/pulls/107","id":222395139,"node_id":"MDExOlB1bGxSZXF1ZXN0MjIyMzk1MTM5","html_url":"https://github.com/allegro/akubra/pull/107","diff_url":"https://github.com/allegro/akubra/pull/107.diff","patch_url":"https://github.com/allegro/akubra/pull/107.patch","issue_url":"https://api.github.com/repos/allegro/akubra/issues/107","number":107,"state":"open","locked":false,"title":"Balancing","user":{"login":"mjarco","id":535518,"node_id":"MDQ6VXNlcjUzNTUxOA==","avatar_url":"https://avatars1.githubusercontent.com/u/535518?v=4","gravatar_id":"","url":"https://api.github.com/users/mjarco","html_url":"https://github.com/mjarco","followers_url":"https://api.github.com/users/mjarco/followers","following_url":"https://api.github.com/users/mjarco/following{/other_user}","gists_url":"https://api.github.com/users/mjarco/gists{/gist_id}","starred_url":"https://api.github.com/users/mjarco/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/mjarco/subscriptions","organizations_url":"https://api.github.com/users/mjarco/orgs","repos_url":"https://api.github.com/users/mjarco/repos","events_url":"https://api.github.com/users/mjarco/events{/privacy}","received_events_url":"https://api.github.com/users/mjarco/received_events","type":"User","site_admin":false},"body":"","created_at":"2018-10-12T08:56:23Z","updated_at":"2018-11-02T10:55:05Z","closed_at":null,"merged_at":null,"merge_commit_sha":"aaf4ebc25af71d7d24ec8a22c8e06bb8acb91b4f","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/allegro/akubra/pulls/107/commits","review_comments_url":"https://api.github.com/repos/allegro/akubra/pulls/107/comments","review_comment_url":"https://api.github.com/repos/allegro/akubra/pulls/comments{/number}","comments_url":"https://api.github.com/repos/allegro/akubra/issues/107/comments","statuses_url":"https://api.github.com/repos/allegro/akubra/statuses/7e5e6ec66bf3263d2d9589b400c38cb9e51606d5","head":{"label":"allegro:balancing","ref":"balancing","sha":"7e5e6ec66bf3263d2d9589b400c38cb9e51606d5","user":{"login":"allegro","id":562236,"node_id":"MDEyOk9yZ2FuaXphdGlvbjU2MjIzNg==","avatar_url":"https://avatars3.githubusercontent.com/u/562236?v=4","gravatar_id":"","url":"https://api.github.com/users/allegro","html_url":"https://github.com/allegro","followers_url":"https://api.github.com/users/allegro/followers","following_url":"https://api.github.com/users/allegro/following{/other_user}","gists_url":"https://api.github.com/users/allegro/gists{/gist_id}","starred_url":"https://api.github.com/users/allegro/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/allegro/subscriptions","organizations_url":"https://api.github.com/users/allegro/orgs","repos_url":"https://api.github.com/users/allegro/repos","events_url":"https://api.github.com/users/allegro/events{/privacy}","received_events_url":"https://api.github.com/users/allegro/received_events","type":"Organization","site_admin":false},"repo":{"id":71782347,"node_id":"MDEwOlJlcG9zaXRvcnk3MTc4MjM0Nw==","name":"akubra","full_name":"allegro/akubra","private":false,"owner":{"login":"allegro","id":562236,"node_id":"MDEyOk9yZ2FuaXphdGlvbjU2MjIzNg==","avatar_url":"https://avatars3.githubusercontent.com/u/562236?v=4","gravatar_id":"","url":"https://api.github.com/users/allegro","html_url":"https://github.com/allegro","followers_url":"https://api.github.com/users/allegro/followers","following_url":"https://api.github.com/users/allegro/following{/other_user}","gists_url":"https://api.github.com/users/allegro/gists{/gist_id}","starred_url":"https://api.github.com/users/allegro/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/allegro/subscriptions","organizations_url":"https://api.github.com/users/allegro/orgs","repos_url":"https://api.github.com/users/allegro/repos","events_url":"https://api.github.com/users/allegro/events{/privacy}","received_events_url":"https://api.github.com/users/allegro/received_events","type":"Organization","site_admin":false},"html_url":"https://github.com/allegro/akubra","description":"Simple solution to keep a independent S3 storages in sync","fork":false,"url":"https://api.github.com/repos/allegro/akubra","forks_url":"https://api.github.com/repos/allegro/akubra/forks","keys_url":"https://api.github.com/repos/allegro/akubra/keys{/key_id}","collaborators_url":"https://api.github.com/repos/allegro/akubra/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/allegro/akubra/teams","hooks_url":"https://api.github.com/repos/allegro/akubra/hooks","issue_events_url":"https://api.github.com/repos/allegro/akubra/issues/events{/number}","events_url":"https://api.github.com/repos/allegro/akubra/events","assignees_url":"https://api.github.com/repos/allegro/akubra/assignees{/user}","branches_url":"https://api.github.com/repos/allegro/akubra/branches{/branch}","tags_url":"https://api.github.com/repos/allegro/akubra/tags","blobs_url":"https://api.github.com/repos/allegro/akubra/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/allegro/akubra/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/allegro/akubra/git/refs{/sha}","trees_url":"https://api.github.com/repos/allegro/akubra/git/trees{/sha}","statuses_url":"https://api.github.com/repos/allegro/akubra/statuses/{sha}","languages_url":"https://api.github.com/repos/allegro/akubra/languages","stargazers_url":"https://api.github.com/repos/allegro/akubra/stargazers","contributors_url":"https://api.github.com/repos/allegro/akubra/contributors","subscribers_url":"https://api.github.com/repos/allegro/akubra/subscribers","subscription_url":"https://api.github.com/repos/allegro/akubra/subscription","commits_url":"https://api.github.com/repos/allegro/akubra/commits{/sha}","git_commits_url":"https://api.github.com/repos/allegro/akubra/git/commits{/sha}","comments_url":"https://api.github.com/repos/allegro/akubra/comments{/number}","issue_comment_url":"https://api.github.com/repos/allegro/akubra/issues/comments{/number}","contents_url":"https://api.github.com/repos/allegro/akubra/contents/{+path}","compare_url":"https://api.github.com/repos/allegro/akubra/compare/{base}...{head}","merges_url":"https://api.github.com/repos/allegro/akubra/merges","archive_url":"https://api.github.com/repos/allegro/akubra/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/allegro/akubra/downloads","issues_url":"https://api.github.com/repos/allegro/akubra/issues{/number}","pulls_url":"https://api.github.com/repos/allegro/akubra/pulls{/number}","milestones_url":"https://api.github.com/repos/allegro/akubra/milestones{/number}","notifications_url":"https://api.github.com/repos/allegro/akubra/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/allegro/akubra/labels{/name}","releases_url":"https://api.github.com/repos/allegro/akubra/releases{/id}","deployments_url":"https://api.github.com/repos/allegro/akubra/deployments","created_at":"2016-10-24T11:32:11Z","updated_at":"2018-09-20T22:34:32Z","pushed_at":"2018-10-31T18:55:39Z","git_url":"git://github.com/allegro/akubra.git","ssh_url":"git@github.com:allegro/akubra.git","clone_url":"https://github.com/allegro/akubra.git","svn_url":"https://github.com/allegro/akubra","homepage":"","size":9006,"stargazers_count":58,"watchers_count":58,"language":"Go","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":6,"mirror_url":null,"archived":false,"open_issues_count":10,"license":{"key":"other","name":"Other","spdx_id":"NOASSERTION","url":null,"node_id":"MDc6TGljZW5zZTA="},"forks":6,"open_issues":10,"watchers":58,"default_branch":"master"}},"base":{"label":"allegro:v1.0","ref":"v1.0","sha":"18ee864e97c7a5ea093e2b02f42c41afc75c480c","user":{"login":"allegro","id":562236,"node_id":"MDEyOk9yZ2FuaXphdGlvbjU2MjIzNg==","avatar_url":"https://avatars3.githubusercontent.com/u/562236?v=4","gravatar_id":"","url":"https://api.github.com/users/allegro","html_url":"https://github.com/allegro","followers_url":"https://api.github.com/users/allegro/followers","following_url":"https://api.github.com/users/allegro/following{/other_user}","gists_url":"https://api.github.com/users/allegro/gists{/gist_id}","starred_url":"https://api.github.com/users/allegro/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/allegro/subscriptions","organizations_url":"https://api.github.com/users/allegro/orgs","repos_url":"https://api.github.com/users/allegro/repos","events_url":"https://api.github.com/users/allegro/events{/privacy}","received_events_url":"https://api.github.com/users/allegro/received_events","type":"Organization","site_admin":false},"repo":{"id":71782347,"node_id":"MDEwOlJlcG9zaXRvcnk3MTc4MjM0Nw==","name":"akubra","full_name":"allegro/akubra","private":false,"owner":{"login":"allegro","id":562236,"node_id":"MDEyOk9yZ2FuaXphdGlvbjU2MjIzNg==","avatar_url":"https://avatars3.githubusercontent.com/u/562236?v=4","gravatar_id":"","url":"https://api.github.com/users/allegro","html_url":"https://github.com/allegro","followers_url":"https://api.github.com/users/allegro/followers","following_url":"https://api.github.com/users/allegro/following{/other_user}","gists_url":"https://api.github.com/users/allegro/gists{/gist_id}","starred_url":"https://api.github.com/users/allegro/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/allegro/subscriptions","organizations_url":"https://api.github.com/users/allegro/orgs","repos_url":"https://api.github.com/users/allegro/repos","events_url":"https://api.github.com/users/allegro/events{/privacy}","received_events_url":"https://api.github.com/users/allegro/received_events","type":"Organization","site_admin":false},"html_url":"https://github.com/allegro/akubra","description":"Simple solution to keep a independent S3 storages in sync","fork":false,"url":"https://api.github.com/repos/allegro/akubra","forks_url":"https://api.github.com/repos/allegro/akubra/forks","keys_url":"https://api.github.com/repos/allegro/akubra/keys{/key_id}","collaborators_url":"https://api.github.com/repos/allegro/akubra/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/allegro/akubra/teams","hooks_url":"https://api.github.com/repos/allegro/akubra/hooks","issue_events_url":"https://api.github.com/repos/allegro/akubra/issues/events{/number}","events_url":"https://api.github.com/repos/allegro/akubra/events","assignees_url":"https://api.github.com/repos/allegro/akubra/assignees{/user}","branches_url":"https://api.github.com/repos/allegro/akubra/branches{/branch}","tags_url":"https://api.github.com/repos/allegro/akubra/tags","blobs_url":"https://api.github.com/repos/allegro/akubra/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/allegro/akubra/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/allegro/akubra/git/refs{/sha}","trees_url":"https://api.github.com/repos/allegro/akubra/git/trees{/sha}","statuses_url":"https://api.github.com/repos/allegro/akubra/statuses/{sha}","languages_url":"https://api.github.com/repos/allegro/akubra/languages","stargazers_url":"https://api.github.com/repos/allegro/akubra/stargazers","contributors_url":"https://api.github.com/repos/allegro/akubra/contributors","subscribers_url":"https://api.github.com/repos/allegro/akubra/subscribers","subscription_url":"https://api.github.com/repos/allegro/akubra/subscription","commits_url":"https://api.github.com/repos/allegro/akubra/commits{/sha}","git_commits_url":"https://api.github.com/repos/allegro/akubra/git/commits{/sha}","comments_url":"https://api.github.com/repos/allegro/akubra/comments{/number}","issue_comment_url":"https://api.github.com/repos/allegro/akubra/issues/comments{/number}","contents_url":"https://api.github.com/repos/allegro/akubra/contents/{+path}","compare_url":"https://api.github.com/repos/allegro/akubra/compare/{base}...{head}","merges_url":"https://api.github.com/repos/allegro/akubra/merges","archive_url":"https://api.github.com/repos/allegro/akubra/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/allegro/akubra/downloads","issues_url":"https://api.github.com/repos/allegro/akubra/issues{/number}","pulls_url":"https://api.github.com/repos/allegro/akubra/pulls{/number}","milestones_url":"https://api.github.com/repos/allegro/akubra/milestones{/number}","notifications_url":"https://api.github.com/repos/allegro/akubra/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/allegro/akubra/labels{/name}","releases_url":"https://api.github.com/repos/allegro/akubra/releases{/id}","deployments_url":"https://api.github.com/repos/allegro/akubra/deployments","created_at":"2016-10-24T11:32:11Z","updated_at":"2018-09-20T22:34:32Z","pushed_at":"2018-10-31T18:55:39Z","git_url":"git://github.com/allegro/akubra.git","ssh_url":"git@github.com:allegro/akubra.git","clone_url":"https://github.com/allegro/akubra.git","svn_url":"https://github.com/allegro/akubra","homepage":"","size":9006,"stargazers_count":58,"watchers_count":58,"language":"Go","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":6,"mirror_url":null,"archived":false,"open_issues_count":10,"license":{"key":"other","name":"Other","spdx_id":"NOASSERTION","url":null,"node_id":"MDc6TGljZW5zZTA="},"forks":6,"open_issues":10,"watchers":58,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/allegro/akubra/pulls/107"},"html":{"href":"https://github.com/allegro/akubra/pull/107"},"issue":{"href":"https://api.github.com/repos/allegro/akubra/issues/107"},"comments":{"href":"https://api.github.com/repos/allegro/akubra/issues/107/comments"},"review_comments":{"href":"https://api.github.com/repos/allegro/akubra/pulls/107/comments"},"review_comment":{"href":"https://api.github.com/repos/allegro/akubra/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/allegro/akubra/pulls/107/commits"},"statuses":{"href":"https://api.github.com/repos/allegro/akubra/statuses/7e5e6ec66bf3263d2d9589b400c38cb9e51606d5"}},"author_association":"CONTRIBUTOR"}}
{"actor":{"display_login":"wookie41"}}