diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 8a2d9e8..cab33d6 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -88,6 +88,21 @@ jobs: title: Integration Test Project token: ${{ steps.get-auth-token.outputs.token }} + - name: Check Copied Project + uses: ./github-script/ + env: + PROJECT_NUMBER: ${{ steps.copy-project.outputs.number }} + OWNER: ${{ steps.copy-project.outputs.owner }} + GH_TOKEN: ${{ steps.get-auth-token.outputs.token }} + README: ${{ steps.copy-project.outputs.readme }} + DESCRIPTION: ${{ steps.copy-project.outputs.description }} + with: + script: | + const assert = require('node:assert'); + + assert.strictEqual(process.env.README, 'README for bar'); + assert.strictEqual(process.env.DESCRIPTION, 'Short description of bar'); + - name: Get Draft Issue ID id: get-draft-issue-id run: | diff --git a/__tests__/copy-project.test.mts b/__tests__/copy-project.test.mts index a20d637..2fe99e4 100644 --- a/__tests__/copy-project.test.mts +++ b/__tests__/copy-project.test.mts @@ -29,8 +29,8 @@ const projectNumber = '94'; const title = 'New Title'; const fieldCount = 4; const itemCount = 50; -const shortDescription = 'Description'; -const readme = 'README'; +const shortDescription = 'Description for {{ foo }}'; +const readme = 'README for {{ foo }}'; const url = 'url'; const templateView = JSON.stringify({ foo: 'bar' @@ -88,23 +88,6 @@ describe('copyProjectAction', () => { ); }); - it('requires drafts if the template-view input is set', async () => { - mockGetInput({ - owner, - 'project-number': projectNumber, - title, - 'template-view': templateView - }); - - await index.copyProjectAction(); - expect(copyProjectActionSpy).toHaveReturned(); - - expect(core.setFailed).toHaveBeenCalledTimes(1); - expect(core.setFailed).toHaveBeenLastCalledWith( - 'Can only use template-view input if drafts are being copied' - ); - }); - it('handles project not found', async () => { mockGetInput({ owner, 'project-number': projectNumber, title }); vi.mocked(copyProject).mockImplementation(() => { @@ -209,6 +192,67 @@ describe('copyProjectAction', () => { ); }); + it('updates description if template changes', async () => { + const newProjectId = 'project-id-2'; + const newProjectNumber = parseInt(projectNumber) + 1; + mockCopyProject(newProjectId, newProjectNumber); + mockGetInput({ + owner, + 'project-number': projectNumber, + title, + 'template-view': templateView + }); + + await index.copyProjectAction(); + expect(copyProjectActionSpy).toHaveReturned(); + expect(editProject).toHaveBeenCalledWith( + owner, + newProjectNumber.toString(), + { + description: 'Description for bar', + readme: expect.anything() + } + ); + }); + + it('updates readme if template changes', async () => { + const newProjectId = 'project-id-2'; + const newProjectNumber = parseInt(projectNumber) + 1; + mockCopyProject(newProjectId, newProjectNumber); + mockGetInput({ + owner, + 'project-number': projectNumber, + title, + 'template-view': templateView + }); + + await index.copyProjectAction(); + expect(copyProjectActionSpy).toHaveReturned(); + expect(editProject).toHaveBeenCalledWith( + owner, + newProjectNumber.toString(), + { + description: expect.anything(), + readme: 'README for bar' + } + ); + }); + + it('does not update readme or description if no template view', async () => { + const newProjectId = 'project-id-2'; + const newProjectNumber = parseInt(projectNumber) + 1; + mockCopyProject(newProjectId, newProjectNumber); + mockGetInput({ + owner, + 'project-number': projectNumber, + title + }); + + await index.copyProjectAction(); + expect(copyProjectActionSpy).toHaveReturned(); + expect(editProject).not.toHaveBeenCalled(); + }); + it('does not get draft issues if no template view', async () => { mockGetInput({ owner, diff --git a/copy-project/README.md b/copy-project/README.md index 800eecf..e302840 100644 --- a/copy-project/README.md +++ b/copy-project/README.md @@ -8,9 +8,9 @@ Copy a GitHub project ### Template Interpolation -The title and body for draft issues can include -[mustache.js](https://github.com/janl/mustache.js) templates which will be -interpolated after the project is copied if the `template-view` input is +The project README, description, and the title and body for draft issues can +include [mustache.js](https://github.com/janl/mustache.js) templates which will +be interpolated after the project is copied if the `template-view` input is provided. ### Field Values diff --git a/copy-project/action.yml b/copy-project/action.yml index b819af8..2f3fa05 100644 --- a/copy-project/action.yml +++ b/copy-project/action.yml @@ -30,7 +30,7 @@ inputs: description: Team to link the project to, in the format `org/team` required: false template-view: - description: A `mustache` template view to be used for template interpolation on draft issues + description: A `mustache` template view to be used for template interpolation on project README, description, and draft issues required: false public: description: Set the project to public or private diff --git a/dist/copy-project.js b/dist/copy-project.js index ace35a2..28ba58e 100644 --- a/dist/copy-project.js +++ b/dist/copy-project.js @@ -95,4 +95,4 @@ ${e.format(t)} var e=Object.prototype.toString,t=Array.isArray||function(t){return e.call(t)===`[object Array]`};function n(e){return typeof e==`function`}function r(e){return t(e)?`array`:typeof e}function i(e){return e.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g,`\\$&`)}function a(e,t){return typeof e==`object`&&!!e&&t in e}function o(e,t){return e!=null&&typeof e!=`object`&&e.hasOwnProperty&&e.hasOwnProperty(t)}var s=RegExp.prototype.test;function c(e,t){return s.call(e,t)}var l=/\S/;function u(e){return!c(l,e)}var d={"&":`&`,"<":`<`,">":`>`,'"':`"`,"'":`'`,"/":`/`,"`":```,"=":`=`};function f(e){return String(e).replace(/[&<>"'`=\/]/g,function(e){return d[e]})}var p=/\s*/,m=/\s+/,h=/\s*=/,g=/\s*\}/,_=/#|\^|\/|>|\{|&|=|!/;function v(e,n){if(!e)return[];var r=!1,a=[],o=[],s=[],c=!1,l=!1,d=``,f=0;function v(){if(c&&!l)for(;s.length;)delete o[s.pop()];else s=[];c=!1,l=!1}var S,C,T;function E(e){if(typeof e==`string`&&(e=e.split(m,2)),!t(e)||e.length!==2)throw Error(`Invalid tags: `+e);S=RegExp(i(e[0])+`\\s*`),C=RegExp(`\\s*`+i(e[1])),T=RegExp(`\\s*`+i(`}`+e[1]))}E(n||w.tags);for(var D=new x(e),O,k,A,j,M,N;!D.eos();){if(O=D.pos,A=D.scanUntil(S),A)for(var P=0,F=A.length;P`?[k,A,O,D.pos,d,f,r]:[k,A,O,D.pos],f++,o.push(M),k===`#`||k===`^`)a.push(M);else if(k===`/`){if(N=a.pop(),!N)throw Error(`Unopened section "`+A+`" at `+O);if(N[1]!==A)throw Error(`Unclosed section "`+N[1]+`" at `+O)}else k===`name`||k===`{`||k===`&`?l=!0:k===`=`&&E(A)}if(v(),N=a.pop(),N)throw Error(`Unclosed section "`+N[1]+`" at `+D.pos);return b(y(o))}function y(e){for(var t=[],n,r,i=0,a=e.length;i0?r[r.length-1][4]:t;break;default:n.push(i)}return t}function x(e){this.string=e,this.tail=e,this.pos=0}x.prototype.eos=function(){return this.tail===``},x.prototype.scan=function(e){var t=this.tail.match(e);if(!t||t.index!==0)return``;var n=t[0];return this.tail=this.tail.substring(n.length),this.pos+=n.length,n},x.prototype.scanUntil=function(e){var t=this.tail.search(e),n;switch(t){case-1:n=this.tail,this.tail=``;break;case 0:n=``;break;default:n=this.tail.substring(0,t),this.tail=this.tail.substring(t)}return this.pos+=n.length,n};function S(e,t){this.view=e,this.cache={".":this.view},this.parent=t}S.prototype.push=function(e){return new S(e,this)},S.prototype.lookup=function(e){var t=this.cache,r;if(t.hasOwnProperty(e))r=t[e];else{for(var i=this,s,c,l,u=!1;i;){if(e.indexOf(`.`)>0)for(s=i.view,c=e.split(`.`),l=0;s!=null&&l`?c=this.renderPartial(o,t,n,i):s===`&`?c=this.unescapedValue(o,t):s===`name`?c=this.escapedValue(o,t,i):s===`text`&&(c=this.rawValue(o)),c!==void 0&&(a+=c);return a},C.prototype.renderSection=function(e,r,i,a,o){var s=this,c=``,l=r.lookup(e[1]);function u(e){return s.render(e,r,i,o)}if(l){if(t(l))for(var d=0,f=l.length;d0||!n)&&(i[a]=r+i[a]);return i.join(` -`)},C.prototype.renderPartial=function(e,t,r,i){if(r){var a=this.getConfigTags(i),o=n(r)?r(e[1]):r[e[1]];if(o!=null){var s=e[6],c=e[5],l=e[4],u=o;c==0&&l&&(u=this.indentPartial(o,l,s));var d=this.parse(u,a);return this.renderTokens(d,t,r,u,i)}}},C.prototype.unescapedValue=function(e,t){var n=t.lookup(e[1]);if(n!=null)return n},C.prototype.escapedValue=function(e,t,n){var r=this.getConfigEscape(n)||w.escape,i=t.lookup(e[1]);if(i!=null)return typeof i==`number`&&r===w.escape?String(i):r(i)},C.prototype.rawValue=function(e){return e[1]},C.prototype.getConfigTags=function(e){if(t(e))return e;if(e&&typeof e==`object`)return e.tags},C.prototype.getConfigEscape=function(e){if(e&&typeof e==`object`&&!t(e))return e.escape};var w={name:`mustache.js`,version:`4.2.0`,tags:[`{{`,`}}`],clearCache:void 0,escape:void 0,parse:void 0,render:void 0,Scanner:void 0,Context:void 0,Writer:void 0,set templateCache(e){T.templateCache=e},get templateCache(){return T.templateCache}},T=new C;return w.clearCache=function(){return T.clearCache()},w.parse=function(e,t){return T.parse(e,t)},w.render=function(e,t,n,i){if(typeof e!=`string`)throw TypeError(`Invalid template! Template should be a "string" but "`+r(e)+`" was given as the first argument for mustache#render(template, view, partials)`);return T.render(e,t,n,i)},w.escape=f,w.Scanner=x,w.Context=S,w.Writer=C,w}))}))();async function bo(){try{let e=Tn(`owner`,{required:!0}),n=Tn(`project-number`,{required:!0}),r=Tn(`title`,{required:!0}),i=En(`drafts`),a=Tn(`target-owner`)||e,o=Tn(`link-to-repository`),s=Tn(`link-to-team`),c=Tn(`template-view`);if(c&&!i){On(`Can only use template-view input if drafts are being copied`);return}let l=null;c&&(l=JSON.parse(c));let u=await po(e,n,a,r,i);if(Tn(`public`)&&await ho(u.owner.login,u.number.toString(),{public:En(`public`)}),o&&await _o(u.number.toString(),o),s&&await vo(u.number.toString(),s),l){let{items:{totalCount:r}}=await go(e,n),i=[];for(let e=0;e<10&&(await t(1e3),i=await fo(u.id),i.length!==r);e++);i.length!==r&&An(`Not all draft issues available for interpolation, expected ${r} but got ${i.length}`);for(let e of i)try{let{content:t}=e,n=yo.render(t.body,l),r=yo.render(t.title,l);(n!==t.body||r!==t.title)&&await mo(u.id,t.id,{body:n,title:r});let i=n.match(//s);if(i){let t=JSON.parse(i[1]);for(let[n,r]of Object.entries(t))await mo(u.id,e.id,{field:n,fieldValue:r})}}catch(t){let n=t?.toString();t instanceof Error&&(n=t.message,t.stack&&Q(t.stack)),An(`Error while doing template replacement on draft issue ${e.id}: ${n}`)}}Dn(`closed`,u.closed),Dn(`field-count`,u.fields.totalCount),Dn(`id`,u.id),Dn(`item-count`,u.items.totalCount),Dn(`number`,u.number),Dn(`owner`,u.owner.login),Dn(`public`,u.public),Dn(`readme`,u.readme),Dn(`description`,u.shortDescription),Dn(`title`,u.title),Dn(`url`,u.url)}catch(e){e instanceof Error&&e.stack&&Q(e.stack),On(e instanceof Error?e.message:JSON.stringify(e))}}bo();export{}; \ No newline at end of file +`)},C.prototype.renderPartial=function(e,t,r,i){if(r){var a=this.getConfigTags(i),o=n(r)?r(e[1]):r[e[1]];if(o!=null){var s=e[6],c=e[5],l=e[4],u=o;c==0&&l&&(u=this.indentPartial(o,l,s));var d=this.parse(u,a);return this.renderTokens(d,t,r,u,i)}}},C.prototype.unescapedValue=function(e,t){var n=t.lookup(e[1]);if(n!=null)return n},C.prototype.escapedValue=function(e,t,n){var r=this.getConfigEscape(n)||w.escape,i=t.lookup(e[1]);if(i!=null)return typeof i==`number`&&r===w.escape?String(i):r(i)},C.prototype.rawValue=function(e){return e[1]},C.prototype.getConfigTags=function(e){if(t(e))return e;if(e&&typeof e==`object`)return e.tags},C.prototype.getConfigEscape=function(e){if(e&&typeof e==`object`&&!t(e))return e.escape};var w={name:`mustache.js`,version:`4.2.0`,tags:[`{{`,`}}`],clearCache:void 0,escape:void 0,parse:void 0,render:void 0,Scanner:void 0,Context:void 0,Writer:void 0,set templateCache(e){T.templateCache=e},get templateCache(){return T.templateCache}},T=new C;return w.clearCache=function(){return T.clearCache()},w.parse=function(e,t){return T.parse(e,t)},w.render=function(e,t,n,i){if(typeof e!=`string`)throw TypeError(`Invalid template! Template should be a "string" but "`+r(e)+`" was given as the first argument for mustache#render(template, view, partials)`);return T.render(e,t,n,i)},w.escape=f,w.Scanner=x,w.Context=S,w.Writer=C,w}))}))();async function bo(){try{let e=Tn(`owner`,{required:!0}),n=Tn(`project-number`,{required:!0}),r=Tn(`title`,{required:!0}),i=En(`drafts`),a=Tn(`target-owner`)||e,o=Tn(`link-to-repository`),s=Tn(`link-to-team`),c=Tn(`template-view`),l=null;c&&(l=JSON.parse(c));let u=await po(e,n,a,r,i),d={};if(Tn(`public`)&&(d.public=En(`public`)),l){let e=yo.render(u.readme,l),t=yo.render(u.shortDescription,l);e!==u.readme&&(d.readme=e),t!==u.shortDescription&&(d.description=t)}if(Object.keys(d).length>0&&await ho(u.owner.login,u.number.toString(),d),o&&await _o(u.number.toString(),o),s&&await vo(u.number.toString(),s),l){let{items:{totalCount:r}}=await go(e,n),i=[];for(let e=0;e<10&&(await t(1e3),i=await fo(u.id),i.length!==r);e++);i.length!==r&&An(`Not all draft issues available for interpolation, expected ${r} but got ${i.length}`);for(let e of i)try{let{content:t}=e,n=yo.render(t.body,l),r=yo.render(t.title,l);(n!==t.body||r!==t.title)&&await mo(u.id,t.id,{body:n,title:r});let i=n.match(//s);if(i){let t=JSON.parse(i[1]);for(let[n,r]of Object.entries(t))await mo(u.id,e.id,{field:n,fieldValue:r})}}catch(t){let n=t?.toString();t instanceof Error&&(n=t.message,t.stack&&Q(t.stack)),An(`Error while doing template replacement on draft issue ${e.id}: ${n}`)}}Dn(`closed`,u.closed),Dn(`field-count`,u.fields.totalCount),Dn(`id`,u.id),Dn(`item-count`,u.items.totalCount),Dn(`number`,u.number),Dn(`owner`,u.owner.login),Dn(`public`,u.public),Dn(`readme`,u.readme),Dn(`description`,u.shortDescription),Dn(`title`,u.title),Dn(`url`,u.url)}catch(e){e instanceof Error&&e.stack&&Q(e.stack),On(e instanceof Error?e.message:JSON.stringify(e))}}bo();export{}; \ No newline at end of file diff --git a/src/copy-project.ts b/src/copy-project.ts index 6e1de4d..7203479 100644 --- a/src/copy-project.ts +++ b/src/copy-project.ts @@ -10,7 +10,8 @@ import { getDraftIssues, getProject, linkProjectToRepository, - linkProjectToTeam + linkProjectToTeam, + type ProjectEdit } from './lib.js'; // oxlint-disable-next-line typescript/no-require-imports, typescript/no-var-requires @@ -30,13 +31,6 @@ export async function copyProjectAction(): Promise { const linkToTeam = core.getInput('link-to-team'); const templateViewString = core.getInput('template-view'); - if (!!templateViewString && !drafts) { - core.setFailed( - 'Can only use template-view input if drafts are being copied' - ); - return; - } - let templateView: Record | null = null; if (templateViewString) { @@ -51,13 +45,34 @@ export async function copyProjectAction(): Promise { drafts ); + const edit: ProjectEdit = {}; + // This is a special case because core.getBooleanInput // will always return false even if not set, but we // need to know if the input has been set at all if (core.getInput('public')) { - await editProject(project.owner.login, project.number.toString(), { - public: core.getBooleanInput('public') - }); + edit.public = core.getBooleanInput('public'); + } + + // Do template interpolation on the project readme and description if a template view was provided + if (templateView) { + const newReadme = Mustache.render(project.readme, templateView); + const newDescription = Mustache.render( + project.shortDescription, + templateView + ); + + if (newReadme !== project.readme) { + edit.readme = newReadme; + } + + if (newDescription !== project.shortDescription) { + edit.description = newDescription; + } + } + + if (Object.keys(edit).length > 0) { + await editProject(project.owner.login, project.number.toString(), edit); } if (linkToRepository) {