WIP Group contributions by URL
This commit is contained in:
@@ -2,6 +2,8 @@ import EmberObject, { computed } from '@ember/object';
|
|||||||
import { isEmpty, isPresent } from '@ember/utils';
|
import { isEmpty, isPresent } from '@ember/utils';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
|
import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key';
|
||||||
|
|
||||||
export default EmberObject.extend({
|
export default EmberObject.extend({
|
||||||
// Contract
|
// Contract
|
||||||
id: null,
|
id: null,
|
||||||
@@ -44,6 +46,13 @@ export default EmberObject.extend({
|
|||||||
return isPresent(this.pendingTx);
|
return isPresent(this.pendingTx);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Stable key shared by all per-contributor contributions created for the
|
||||||
|
// same issue/pull request (derived from `url`). Manual contributions without
|
||||||
|
// a URL return null and are treated as singletons (not grouped).
|
||||||
|
groupId: computed('url', function() {
|
||||||
|
return contributionGroupingKey(this);
|
||||||
|
}),
|
||||||
|
|
||||||
serialize () {
|
serialize () {
|
||||||
return JSON.stringify(this);
|
return JSON.stringify(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,74 @@ export default Service.extend({
|
|||||||
return kreditsByContributor;
|
return kreditsByContributor;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Groups contributions that were created together for the same
|
||||||
|
// issue/pull request (one contribution per contributor). The grouping key
|
||||||
|
// is the contribution's `url` (see utils/contribution-grouping-key).
|
||||||
|
// Manual contributions without a URL each form their own single-item group
|
||||||
|
// (they are never collapsed together, since `null` is not a real key).
|
||||||
|
//
|
||||||
|
// Each group is an EmberObject with: groupId, items, contributors,
|
||||||
|
// contributorIds, totalAmount, kind, description, url, date, isGrouped
|
||||||
|
// (true when more than one contribution shares the key).
|
||||||
|
contributionsGrouped: computed('contributions.[]', function() {
|
||||||
|
const groupsMap = new Map();
|
||||||
|
const singletons = [];
|
||||||
|
|
||||||
|
this.contributions.forEach(c => {
|
||||||
|
const groupId = c.groupId;
|
||||||
|
if (isEmpty(groupId)) {
|
||||||
|
singletons.push(c);
|
||||||
|
} else if (groupsMap.has(groupId)) {
|
||||||
|
groupsMap.get(groupId).items.push(c);
|
||||||
|
} else {
|
||||||
|
groupsMap.set(groupId, { groupId, items: [c] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
...groupsMap.values(),
|
||||||
|
...singletons.map(c => ({ groupId: null, items: [c] }))
|
||||||
|
];
|
||||||
|
|
||||||
|
return groups.map(group => {
|
||||||
|
const items = group.items;
|
||||||
|
const first = items.firstObject;
|
||||||
|
const contributorIds = items.mapBy('contributorId').uniq();
|
||||||
|
const contributors = contributorIds
|
||||||
|
.map(id => this.contributors.findBy('id', id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const totalAmount = items
|
||||||
|
.mapBy('amount')
|
||||||
|
.reduce((a, b) => a + (b || 0), 0);
|
||||||
|
|
||||||
|
return EmberObject.create({
|
||||||
|
groupId: group.groupId,
|
||||||
|
items,
|
||||||
|
contributors,
|
||||||
|
contributorIds,
|
||||||
|
totalAmount,
|
||||||
|
kind: first ? first.kind : null,
|
||||||
|
description: first ? first.description : null,
|
||||||
|
url: first ? first.url : null,
|
||||||
|
date: first ? first.date : null,
|
||||||
|
isGrouped: items.length > 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Returns the other contributions that share a grouping key with the given
|
||||||
|
// contribution (i.e. the co-contributors' contributions). Returns an empty
|
||||||
|
// array when the contribution has no group (manual, no URL) or has no
|
||||||
|
// siblings.
|
||||||
|
siblingContributions (contribution) {
|
||||||
|
if (!contribution) return [];
|
||||||
|
const groupId = contribution.groupId;
|
||||||
|
if (isEmpty(groupId)) return [];
|
||||||
|
const group = this.contributionsGrouped.findBy('groupId', groupId);
|
||||||
|
if (!group) return [];
|
||||||
|
return group.items.filter(c => c !== contribution);
|
||||||
|
},
|
||||||
|
|
||||||
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
|
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
|
||||||
return this.contributions
|
return this.contributions
|
||||||
.filter(c => c.confirmedAt > this.currentBlock);
|
.filter(c => c.confirmedAt > this.currentBlock);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { isEmpty } from '@ember/utils';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Derives a stable grouping key for a contribution, so that contributions
|
||||||
|
// created for the same issue/pull request (one per contributor) can be
|
||||||
|
// linked together in the UI.
|
||||||
|
//
|
||||||
|
// The bot writes the exact same `url` string (the html_url of the issue/PR)
|
||||||
|
// into every per-contributor IPFS document for a given piece of work, so the
|
||||||
|
// top-level `url` field on the model is sufficient as a grouping key. Manual
|
||||||
|
// contributions without a URL return null and are treated as singletons.
|
||||||
|
//
|
||||||
|
export default function contributionGroupingKey (contribution) {
|
||||||
|
let url = contribution ? contribution.url : null;
|
||||||
|
if (isEmpty(url)) return null;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
Vendored
+4
-4
@@ -5,14 +5,14 @@ import processContributionData from 'kredits-web/utils/process-contribution-data
|
|||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{ id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev' },
|
{ id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196' },
|
||||||
{ id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops' },
|
{ id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops' },
|
||||||
{ id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops' },
|
{ id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops', url: 'https://github.com/67P/kredits-contracts/pull/196' },
|
||||||
{ id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs' },
|
{ id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs' },
|
||||||
{ id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design' },
|
{ id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design' },
|
||||||
{ id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev' },
|
{ id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev' },
|
||||||
{ id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev' },
|
{ id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' },
|
||||||
{ id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community' },
|
{ id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231' },
|
||||||
{ id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs' },
|
{ id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,78 @@ module('Unit | Service | kredits', function(hooks) {
|
|||||||
assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name');
|
assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('#contributionsGrouped groups contributions sharing a url', function(assert) {
|
||||||
|
let service = this.owner.lookup('service:kredits');
|
||||||
|
service.set('contributors', contributors);
|
||||||
|
service.set('contributions', contributions);
|
||||||
|
|
||||||
|
const grouped = service.contributionsGrouped;
|
||||||
|
|
||||||
|
// 9 contributions, with ids 1+3 sharing a url and ids 7+8 sharing a url,
|
||||||
|
// so 7 groups total (2 grouped + 5 singletons).
|
||||||
|
assert.equal(grouped.length, 7, 'produces one group per unique key plus singletons');
|
||||||
|
|
||||||
|
const ghGroup = grouped.findBy('groupId', 'https://github.com/67P/kredits-contracts/pull/196');
|
||||||
|
assert.ok(ghGroup, 'github url group exists');
|
||||||
|
assert.ok(ghGroup.isGrouped, 'github group is flagged as grouped');
|
||||||
|
assert.equal(ghGroup.items.length, 2, 'github group has two contributions');
|
||||||
|
assert.deepEqual(ghGroup.contributorIds, [1, 2], 'github group lists both contributor ids');
|
||||||
|
assert.equal(ghGroup.contributors.length, 2, 'github group resolves both contributors');
|
||||||
|
assert.equal(ghGroup.totalAmount, 3000, 'github group sums amounts (1500 + 1500)');
|
||||||
|
assert.equal(ghGroup.kind, 'dev', 'github group takes kind from first item');
|
||||||
|
assert.equal(ghGroup.url, 'https://github.com/67P/kredits-contracts/pull/196');
|
||||||
|
|
||||||
|
const giteaGroup = grouped.findBy('groupId', 'https://gitea.kosmos.org/kredits/kredits-web/issues/231');
|
||||||
|
assert.ok(giteaGroup, 'gitea url group exists');
|
||||||
|
assert.ok(giteaGroup.isGrouped, 'gitea group is flagged as grouped');
|
||||||
|
assert.deepEqual(giteaGroup.contributorIds, [3, 1], 'gitea group lists both contributor ids');
|
||||||
|
assert.equal(giteaGroup.totalAmount, 6500, 'gitea group sums amounts (5000 + 1500)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#contributionsGrouped treats manual contributions without url as singletons', function(assert) {
|
||||||
|
let service = this.owner.lookup('service:kredits');
|
||||||
|
service.set('contributors', contributors);
|
||||||
|
service.set('contributions', contributions);
|
||||||
|
|
||||||
|
const grouped = service.contributionsGrouped;
|
||||||
|
const singletons = grouped.filterBy('isGrouped', false);
|
||||||
|
|
||||||
|
// 5 contributions without a url: ids 2, 4, 5, 6, 9
|
||||||
|
assert.equal(singletons.length, 5, 'one singleton group per url-less contribution');
|
||||||
|
singletons.forEach(g => {
|
||||||
|
assert.equal(g.items.length, 1, 'singleton group has exactly one item');
|
||||||
|
assert.notOk(g.groupId, 'singleton group has a null/empty groupId');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#siblingContributions returns co-contributor contributions for the same url', function(assert) {
|
||||||
|
let service = this.owner.lookup('service:kredits');
|
||||||
|
service.set('contributors', contributors);
|
||||||
|
service.set('contributions', contributions);
|
||||||
|
|
||||||
|
const c1 = contributions.findBy('id', 1);
|
||||||
|
const siblings = service.siblingContributions(c1);
|
||||||
|
assert.equal(siblings.length, 1, 'contribution 1 has one sibling');
|
||||||
|
assert.equal(siblings[0].id, 3, 'the sibling is contribution 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#siblingContributions returns empty for manual contributions', function(assert) {
|
||||||
|
let service = this.owner.lookup('service:kredits');
|
||||||
|
service.set('contributors', contributors);
|
||||||
|
service.set('contributions', contributions);
|
||||||
|
|
||||||
|
const c2 = contributions.findBy('id', 2);
|
||||||
|
assert.deepEqual(service.siblingContributions(c2), [], 'no siblings for url-less contribution');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#siblingContributions returns empty for null contribution', function(assert) {
|
||||||
|
let service = this.owner.lookup('service:kredits');
|
||||||
|
service.set('contributors', contributors);
|
||||||
|
service.set('contributions', contributions);
|
||||||
|
|
||||||
|
assert.deepEqual(service.siblingContributions(null), [], 'no siblings for null');
|
||||||
|
});
|
||||||
|
|
||||||
test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) {
|
test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) {
|
||||||
let service = this.owner.lookup('service:kredits');
|
let service = this.owner.lookup('service:kredits');
|
||||||
service.set('contributors', contributors);
|
service.set('contributors', contributors);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import EmberObject from '@ember/object';
|
||||||
|
import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key';
|
||||||
|
|
||||||
|
function makeContribution (url) {
|
||||||
|
return EmberObject.create({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Unit | Utils | contribution-grouping-key', function() {
|
||||||
|
|
||||||
|
test('returns the url when present', function(assert) {
|
||||||
|
const c = makeContribution('https://github.com/67P/kredits-contracts/pull/196');
|
||||||
|
assert.equal(contributionGroupingKey(c), 'https://github.com/67P/kredits-contracts/pull/196');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the same key for identical github urls', function(assert) {
|
||||||
|
const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196');
|
||||||
|
const b = makeContribution('https://github.com/67P/kredits-contracts/pull/196');
|
||||||
|
assert.equal(contributionGroupingKey(a), contributionGroupingKey(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the same key for identical gitea urls', function(assert) {
|
||||||
|
const a = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231');
|
||||||
|
const b = makeContribution('https://gitea.kosmos.org/kredits/kredits-web/issues/231');
|
||||||
|
assert.equal(contributionGroupingKey(a), contributionGroupingKey(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns different keys for different urls', function(assert) {
|
||||||
|
const a = makeContribution('https://github.com/67P/kredits-contracts/pull/196');
|
||||||
|
const b = makeContribution('https://github.com/67P/kredits-contracts/pull/197');
|
||||||
|
assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when url is empty', function(assert) {
|
||||||
|
const c = makeContribution('');
|
||||||
|
assert.equal(contributionGroupingKey(c), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when url is null/undefined', function(assert) {
|
||||||
|
assert.equal(contributionGroupingKey(makeContribution(null)), null);
|
||||||
|
assert.equal(contributionGroupingKey(makeContribution(undefined)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when contribution is null/undefined', function(assert) {
|
||||||
|
assert.equal(contributionGroupingKey(null), null);
|
||||||
|
assert.equal(contributionGroupingKey(undefined), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user