Skip to content
This repository was archived by the owner on Jul 11, 2023. It is now read-only.

Commit ee5f08b

Browse files
authored
Merge pull request #5 from jonabc/card-filters
Add filtering when mirroring columns
2 parents ba77ea6 + e64b72a commit ee5f08b

7 files changed

Lines changed: 213 additions & 100 deletions

File tree

.github/workflows/mirror.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: "mirror repo projects"
22
on: # run this on a cron schedule
33
schedule:
4-
- cron: '*/3 * * * *'
4+
- cron: '*/5 * * * *'
55
push:
66

77
jobs:
@@ -11,6 +11,6 @@ jobs:
1111
- uses: actions/checkout@v2
1212
- uses: ./
1313
with:
14-
sourceColumnId: MDEzOlByb2plY3RDb2x1bW44NTA2NzUz
15-
targetColumnId: MDEzOlByb2plY3RDb2x1bW44NTA2NzYw
14+
source_column_id: MDEzOlByb2plY3RDb2x1bW44NTA2NzUz
15+
target_column_id: MDEzOlByb2plY3RDb2x1bW44NTA2NzYw
1616
github_token: ${{ secrets.TEST_SECRET }}

README.md

Lines changed: 45 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,100 +2,72 @@
22
<a href="https://github.com/actions/typescript-action/actions"><img alt="typescript-action status" src="https://github.com/actions/typescript-action/workflows/build-test/badge.svg"></a>
33
</p>
44

5-
# Create a JavaScript Action using TypeScript
5+
# GitHub Projects Column Mirror
66

7-
Use this template to bootstrap the creation of a JavaScript action.:rocket:
7+
This is a GitHub Action to mirror columns in hierarchically modeled GitHub Projects.
88

9-
This template includes compilication support, tests, a validation workflow, publishing, and versioning guidance.
9+
As an example, let's assume a scenario where there are issues used to document both epic and feature level scopes of work, where features belong to epics.
1010

11-
If you are new, there's also a simpler introduction. See the [Hello World JavaScript Action](https://github.com/actions/hello-world-javascript-action)
11+
In this scenario, we have two project boards to individually track and give visibility into epics and features individually. Each project board has the following columns:
12+
- backlog
13+
- active
14+
- done
1215

13-
## Create an action from this template
16+
If individual teams, or individual roles within a team, are only looking at the feature board then it can be hard to tell what epics are actively being worked on.
1417

15-
Click the `Use this Template` and provide the new repo details for your action
18+
This action makes this scenario easier by actively mirroring columns across project boards. We can create an `active epics` column on the feature board that will automatically stay up to date with the `active` column on the epics project board.
1619

17-
## Code in Master
20+
## Usage
1821

19-
Install the dependencies
20-
```bash
21-
$ npm install
22-
```
22+
The action is intended to be run on a cron schedule, see [mirror.yml](./.github/workflows/mirror.yml) for an example. The linked action workflow also uses the `push` event trigger for testing purposes only, and is not generally recommended for use.
2323

24-
Build the typescript and package it for distribution
25-
```bash
26-
$ npm run build && npm run pack
2724
```
28-
29-
Run the tests :heavy_check_mark:
30-
```bash
31-
$ npm test
32-
33-
PASS ./index.test.js
34-
✓ throws invalid number (3ms)
35-
wait 500 ms (504ms)
36-
test runs (95ms)
37-
38-
...
25+
on:
26+
schedule:
27+
cron:
28+
# cron actions will not run more frequently than once every 5 minutes
29+
- '*/5 * * * *'
30+
jobs:
31+
mirror_column:
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v2
35+
- uses: jonabc/linked-project-columns@<version>
36+
with:
37+
source_column_id: <column node id>
38+
target_column_id: <column node id>
39+
github_token: ${{ secrets.MIRROR_SECRET_PAT }}
3940
```
4041

41-
## Change action.yml
42-
43-
The action.yml contains defines the inputs and output for your action.
44-
45-
Update the action.yml with your name, description, inputs and outputs for your action.
46-
47-
See the [documentation](https://help.github.com/en/articles/metadata-syntax-for-github-actions)
42+
### Added notice card
4843

49-
## Change the Code
44+
The action will add a notice to the top of the target project column, identifying the source project column and notifying users that the column is automatically managed.
5045

51-
Most toolkit and CI/CD operations involve async operations so the action is run in an async function.
46+
### Required permissions
5247

53-
```javascript
54-
import * as core from '@actions/core';
55-
...
48+
The `${{ secrets.GITHUB_TOKEN }}` token can be used only when all project columns being accessed live in the target repository. For organization or user owned projects, a personal access token will need to be used that has the following permissions at a minimum:
49+
1. `write:org` to update organization projects
50+
2. `repo` to access information in private repositories
51+
3. `user` to access information in user repositories (if needed)
5652

57-
async function run() {
58-
try {
59-
...
60-
}
61-
catch (error) {
62-
core.setFailed(error.message);
63-
}
64-
}
53+
### Filtering cards mirroring
6554

66-
run()
67-
```
68-
69-
See the [toolkit documentation](https://github.com/actions/toolkit/blob/master/README.md#packages) for the various packages.
55+
Cards can be filtered from mirroring by specifying additional inputs on the action workflow.
7056

71-
## Publish to a distribution branch
57+
**type_filter**
7258

73-
Actions are run from GitHub repos so we will checkin the packed dist folder.
74-
75-
Then run [ncc](https://github.com/zeit/ncc) and push the results:
76-
```bash
77-
$ npm run pack
78-
$ git add dist
79-
$ git commit -a -m "prod dependencies"
80-
$ git push origin releases/v1
81-
```
59+
Filters mirrored cards only to the specified type. Must be one of
60+
- `content` (linked issue or PR)
61+
- `note`
8262

83-
Your action is now published! :rocket:
63+
**label_filter**
8464

85-
See the [versioning documentation](https://github.com/actions/toolkit/blob/master/docs/action-versioning.md)
65+
Filters mirrored cards based labels that match the input. Note cards cannot be tagged with labels, and will never be filtered based on this input.
8666

87-
## Validate
88-
89-
You can now validate the action by referencing `./` in a workflow in your repo (see [test.yml](.github/workflows/test.yml)])
90-
91-
```yaml
92-
uses: ./
93-
with:
94-
milliseconds: 1000
95-
```
67+
Label filters use exact, case sensitive comparisons to determine whether to mirror a card. Label filter inputs can contain multiple labels separated by commas (`'first, second'`), and will be mirrored if any labels match (i.e. `OR` logic).
9668

97-
See the [actions tab](https://github.com/actions/javascript-action/actions) for runs of this action! :rocket:
69+
**content_filter**
9870

99-
## Usage:
71+
Filters mirrored cards based on the displayed card content. Issue/PR titles is evaluated when they are linked as cards, otherwise the card's note text is used.
10072

101-
After testing you can [create a v1 tag](https://github.com/actions/toolkit/blob/master/docs/action-versioning.md) to reference the stable and latest V1 action
73+
Content filters use partial, case insensitive comparisons when determining which cards to mirror. Content filter inputs can contain multiple filters separated by commas (`'first, second'`), and will be mirrored if any content matches are found (i.e. `OR` logic). Content filters containing commas must be wrapped in quotes (`'first, second, "matching, with a comma"'`)

action.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@ name: 'GitHub Projects Column Mirror'
22
description: 'Reflects all changes to a source project column in a target project column'
33
author: 'Jon Ruskin'
44
inputs:
5-
sourceColumnId:
5+
source_column_id:
66
description: "Id of the source project column"
77
required: true
8-
targetColumnId:
8+
target_column_id:
99
description: "Id of the target project column"
1010
required: true
1111
github_token:
1212
description: "GitHub token to use for authenticated API calls"
1313
required: false
1414
default: ${{ github.token }}
15+
type_filter:
16+
description: "Filter to a single card type, either 'note' or 'content'"
17+
required: false
18+
label_filter:
19+
description: "Filter to cards containing matching labels"
20+
required: false
21+
content_filter:
22+
description: "Filter to cards with matching note content or issue/PR title"
23+
required: false
1524
runs:
1625
using: 'node12'
1726
main: 'dist/index.js'

dist/index.js

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1923,15 +1923,24 @@ module.exports = require("child_process");
19231923
"use strict";
19241924

19251925
Object.defineProperty(exports, "__esModule", { value: true });
1926+
const projectCardContentFields = `
1927+
id
1928+
title
1929+
labels(first: 20) {
1930+
nodes {
1931+
name
1932+
}
1933+
}
1934+
`.trim();
19261935
const projectCardFields = `
19271936
id
19281937
note
19291938
content {
19301939
... on Issue {
1931-
id
1940+
${projectCardContentFields}
19321941
}
19331942
... on PullRequest {
1934-
id
1943+
${projectCardContentFields}
19351944
}
19361945
}
19371946
`.trim();
@@ -2212,7 +2221,6 @@ function ensureCard(card, index, targetColumn) {
22122221
}
22132222
if (!targetCard) {
22142223
// add!
2215-
core.info(`Creating card in ${targetColumn.name}`);
22162224
const cardData = {};
22172225
if (card.content) {
22182226
cardData.contentId = card.content.id;
@@ -2228,11 +2236,9 @@ function ensureCard(card, index, targetColumn) {
22282236
targetCard = response['addProjectCard'].cardEdge.node;
22292237
targetColumn.cards.nodes.unshift(targetCard);
22302238
targetCardIndex = 0;
2231-
core.info(`created card: ${targetCard.id}`);
22322239
}
22332240
if (targetCardIndex !== index) {
22342241
// move!
2235-
core.info(`moving card: ${targetCard.id}`);
22362242
const moveData = {
22372243
cardId: targetCard.id,
22382244
columnId: targetColumn.id,
@@ -2242,16 +2248,66 @@ function ensureCard(card, index, targetColumn) {
22422248
// update the target column card location in the local card array
22432249
targetColumn.cards.nodes.splice(index, 0, targetColumn.cards.nodes.splice(targetCardIndex, 1)[0]);
22442250
targetCardIndex = index;
2245-
core.info(`moved card: ${targetCard.id} after ${moveData.afterCardId}`);
22462251
}
22472252
return targetCard;
22482253
});
22492254
}
2255+
// content filters are parsed from a string either as wrapped in quotes or comma separated
2256+
const FILTER_LIST_REGEX = /\s*(?:((["'])([^\2]+?)\2)|([^"',]+))\s*/g;
2257+
function getFilterList(input) {
2258+
if (!input) {
2259+
return [];
2260+
}
2261+
return [...input.matchAll(FILTER_LIST_REGEX)]
2262+
.map(match => match[3] || match[4])
2263+
.map(filter => filter.trim())
2264+
.filter(filter => !!filter);
2265+
}
2266+
function applyFilters(column) {
2267+
const typeFilter = core.getInput('type_filter', { required: false });
2268+
if (typeFilter === 'note') {
2269+
column.cards.nodes = column.cards.nodes.filter(card => !!card.note);
2270+
}
2271+
else if (typeFilter === 'content') {
2272+
column.cards.nodes = column.cards.nodes.filter(card => !!card.content);
2273+
}
2274+
else if (typeFilter) {
2275+
core.warning(`cannot apply unknown type_filter ${typeFilter}`);
2276+
}
2277+
const contentFilters = getFilterList(core.getInput('content_filter', { required: false }));
2278+
if (contentFilters.length > 0) {
2279+
// match content in case-insensitive manner
2280+
const contentMatchers = contentFilters.map(filter => new RegExp(filter, 'i'));
2281+
// filter to cards with displayed text content that matches at least one
2282+
// of the user-supplied filters
2283+
column.cards.nodes = column.cards.nodes.filter((card) => {
2284+
if (card.content) {
2285+
return contentMatchers.some(filter => filter.test(card.content.title));
2286+
}
2287+
else if (card.note) {
2288+
return contentMatchers.some(filter => filter.test(card.note));
2289+
}
2290+
// don't filter cards that cannot be filtered by content
2291+
return true;
2292+
});
2293+
}
2294+
const labelFilters = getFilterList(core.getInput('label_filter', { required: false }));
2295+
if (labelFilters.length > 0) {
2296+
column.cards.nodes = column.cards.nodes.filter((card) => {
2297+
if (card.content) {
2298+
// only include cards for issues and PRs that have a matching label
2299+
return card.content.labels.nodes.some(label => labelFilters.includes(label.name));
2300+
}
2301+
// don't filter cards that can't be filtered by labels
2302+
return true;
2303+
});
2304+
}
2305+
}
22502306
function run() {
22512307
return __awaiter(this, void 0, void 0, function* () {
22522308
try {
2253-
const sourceColumnId = core.getInput('sourceColumnId');
2254-
const targetColumnId = core.getInput('targetColumnId');
2309+
const sourceColumnId = core.getInput('source_column_id');
2310+
const targetColumnId = core.getInput('target_column_id');
22552311
const response = yield github(queries.GET_PROJECT_COLUMNS, {
22562312
sourceColumnId,
22572313
targetColumnId,
@@ -2262,6 +2318,9 @@ function run() {
22622318
}
22632319
const sourceColumn = response['sourceColumn'];
22642320
const targetColumn = response['targetColumn'];
2321+
// apply user supplied filters to cards from the source column and mirror the
2322+
// target column based on the remaining filters
2323+
applyFilters(sourceColumn);
22652324
// make sure that a card explaining the automation on the column exists
22662325
// at index 0 in the target column
22672326
const automationNoteCard = {
@@ -2270,8 +2329,8 @@ function run() {
22702329
yield ensureCard(automationNoteCard, 0, targetColumn);
22712330
// delete all cards in target column that do not exist in the source column,
22722331
// except for the automation note
2273-
// start at index 1 to account for the automation note card
2274-
for (let index = 1; index < targetColumn.cards.nodes.length; index++) {
2332+
// don't iterate over index 0, to account for the automation note card
2333+
for (let index = targetColumn.cards.nodes.length - 1; index >= 1; index--) {
22752334
const targetCard = targetColumn.cards.nodes[index];
22762335
const [sourceCard] = findCard(targetCard, sourceColumn);
22772336
if (!sourceCard) {

src/graphql.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1+
const projectCardContentFields = `
2+
id
3+
title
4+
labels(first: 20) {
5+
nodes {
6+
name
7+
}
8+
}
9+
`.trim()
10+
111
const projectCardFields = `
212
id
313
note
414
content {
515
... on Issue {
6-
id
16+
${projectCardContentFields}
717
}
818
... on PullRequest {
9-
id
19+
${projectCardContentFields}
1020
}
1121
}
1222
`.trim()

0 commit comments

Comments
 (0)